Files
log-viewer/forensic_viewer-php.bk
T
2026-08-05 01:30:50 +00:00

1494 lines
76 KiB
Plaintext

<?php
/**
* SIMRS Forensic Inspector — Standalone Dashboard
*
* Dashboard visual untuk memantau, mendiagnosis, dan memforensik log sistem SIMRS
* (Session, Auth, Error, Performance, Security).
*
* Standalone & Keycloak SSO Direct Login.
*/
require_once __DIR__ . '/core/main.php';
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Action: Logout Keycloak SSO
if (isset($_GET['action']) && $_GET['action'] === 'logout') {
unset($_SESSION['forensic_user']);
unset($_SESSION['SES_REG']);
header('Location: forensic_viewer.php');
exit;
}
// Default Keycloak Config (dapat dikonfigurasi via .env atau input form)
$envKcServer = getenv('KEYCLOAK_URL') ?: getenv('SSO_URL');
$envKcRealm = getenv('KEYCLOAK_REALM');
$envKcClient = getenv('KEYCLOAK_CLIENT_ID');
$envKcSecret = getenv('KEYCLOAK_CLIENT_SECRET');
$isKcEnvConfigured = !empty($envKcServer) || (!empty($envKcRealm) && !empty($envKcClient));
$defaultKcServer = $envKcServer ?: 'https://auth.rssa.top/';
$defaultKcRealm = $envKcRealm ?: 'rssa';
$defaultKcClient = $envKcClient ?: 'satu';
$defaultKcSecret = $envKcSecret ?: 'ZhkK45MHB0a0eAZX5ecNTnlfnWlZXfBE';
// Action: Handle Keycloak Direct SSO & Temporary Local Login POST
$loginError = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'keycloak_login') {
$username = trim($_POST['username'] ?? '');
$password = trim($_POST['password'] ?? '');
$kcServer = trim($_POST['kc_server'] ?? $defaultKcServer);
$kcRealm = trim($_POST['kc_realm'] ?? $defaultKcRealm);
$kcClientId = trim($_POST['kc_client_id'] ?? $defaultKcClient);
$kcClientSecret = trim($_POST['kc_client_secret'] ?? $defaultKcSecret);
if (empty($username) || empty($password)) {
$loginError = 'Username dan Password wajib diisi.';
} else {
// 1. Validasi Akun Login Sementara (Dev & Transition Mode)
if (($username === '[email protected]' || $username === 'stim') && $password === 'RSSAjaya2026') {
$_SESSION['forensic_user'] = [
'username' => '[email protected]',
'name' => 'STIM RSSA Admin (Pengembangan SSO)',
'email' => '[email protected]',
'login_time' => date('Y-m-d H:i:s'),
'auth_type' => 'temporary_local',
'kc_server' => 'local',
'kc_realm' => 'simrs',
];
if (isset($forensic)) {
$forensic->logAuth('login_success_temporary', [
'user_nip' => '[email protected]',
'auth_type' => 'temporary_local',
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
], 'INFO');
}
header('Location: forensic_viewer.php');
exit;
}
// 2. Autentikasi Direct Keycloak SSO
$baseServer = rtrim($kcServer, '/');
if (strpos($baseServer, '/protocol/openid-connect') !== false) {
$tokenEndpoint = $baseServer;
} elseif (strpos($baseServer, '/realms/') !== false) {
$tokenEndpoint = $baseServer . '/protocol/openid-connect/token';
} else {
$tokenEndpoint = $baseServer . '/realms/' . rawurlencode($kcRealm) . '/protocol/openid-connect/token';
}
$postParams = [
'grant_type' => 'password',
'client_id' => $kcClientId,
'username' => $username,
'password' => $password,
];
if (!empty($kcClientSecret)) {
$postParams['client_secret'] = $kcClientSecret;
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $tokenEndpoint);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postParams));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 8);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($curlErr) {
$loginError = 'Gagal terhubung ke Server Keycloak SSO (' . htmlspecialchars($tokenEndpoint) . '): ' . $curlErr;
} else {
$jsonData = json_decode($response, true);
if ($httpCode === 200 && !empty($jsonData['access_token'])) {
$tokenParts = explode('.', $jsonData['access_token']);
$payload = [];
if (isset($tokenParts[1])) {
$decoded = base64_decode(strtr($tokenParts[1], '-_', '+/'));
$payload = json_decode($decoded, true) ?: [];
}
$_SESSION['forensic_user'] = [
'username' => $payload['preferred_username'] ?? $username,
'name' => $payload['name'] ?? ($payload['preferred_username'] ?? $username),
'email' => $payload['email'] ?? '',
'login_time' => date('Y-m-d H:i:s'),
'access_token' => $jsonData['access_token'],
'auth_type' => 'keycloak_sso',
'kc_server' => $kcServer,
'kc_realm' => $kcRealm,
];
if (isset($forensic)) {
$forensic->logAuth('login_success', [
'user_nip' => $username,
'sso_type' => 'keycloak_direct',
'realm' => $kcRealm,
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
], 'INFO');
}
header('Location: forensic_viewer.php');
exit;
} else {
$errMsg = $jsonData['error_description'] ?? ($jsonData['error'] ?? 'Autentikasi Gagal (HTTP ' . $httpCode . ')');
$loginError = 'Login Error: Username / Password tidak sesuai atau ' . $errMsg;
if (isset($forensic)) {
$forensic->logAuth('login_failed', [
'user_nip' => $username,
'sso_type' => 'keycloak_direct',
'http_code' => $httpCode,
'error' => $errMsg,
], 'WARNING');
}
}
}
}
}
// Access Check: forensic_viewer.php WAJIB Login terlebih dahulu! (Direct access dilarang tanpa sesi aktif)
$isSsoLoggedIn = !empty($_SESSION['forensic_user']) || !empty($_SESSION['SES_REG']);
// Form login Keycloak SSO & Local (Tampil otomatis jika pengguna belum terautentikasi)
if (!$isSsoLoggedIn):
?>
<!DOCTYPE html>
<html lang="id">
<head>
<base href="//<?= $_SERVER['HTTP_HOST'] ?>/">
<?php if ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https')): ?>
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">
<?php endif; ?>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login Keycloak SSO — SIMRS Forensic Inspector</title>
<link rel="stylesheet" href="library/bootstrap-4.1.3/css/bootstrap.min.css">
<link rel="stylesheet" href="library/fontawesome-free-5.10.2-web/css/all.min.css">
<style>
body { background: radial-gradient(circle at top right, #1e293b, #0f172a); color: #e2e8f0; font-family: 'Segoe UI', sans-serif; min-height: 100vh; display: flex; align-items: center; justify-content: center; margin: 0; padding: 1.5rem; }
.login-card { background: #1e293b; border: 1px solid #334155; border-radius: 16px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6); width: 100%; max-width: 440px; padding: 2.5rem; }
.brand-icon { width: 68px; height: 68px; background: linear-gradient(135deg, #0d9488, #0284c7); border-radius: 18px; display: flex; align-items: center; justify-content: center; margin: 0 auto 1.25rem; box-shadow: 0 10px 20px -3px rgba(13, 148, 136, 0.4); }
.btn-teal { background-color: #0d9488; color: #fff; border: none; font-weight: 600; padding: 0.75rem; border-radius: 8px; font-size: 1rem; transition: background-color 0.2s; }
.btn-teal:hover { background-color: #0f766e; color: #fff; }
.form-control-dark { background-color: #0f172a; border: 1px solid #334155; color: #f8fafc; border-radius: 8px; padding: 0.65rem 0.9rem; }
.form-control-dark:focus { background-color: #1e293b; color: #fff; border-color: #38bdf8; box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.25); }
.config-accordion { background: #0f172a; border: 1px solid #334155; border-radius: 8px; padding: 1rem; margin-top: 0.75rem; }
</style>
</head>
<body>
<div class="login-card text-center">
<div class="brand-icon">
<i class="fas fa-key fa-2x text-white"></i>
</div>
<h4 class="font-weight-bold text-white mb-1">SIMRS Forensic Inspector</h4>
<p class="text-muted small mb-3">Masuk menggunakan Keycloak SSO atau Akun Login Sementara</p>
<!-- <div class="alert alert-dark border border-info text-left small mb-3" style="background: rgba(6, 182, 212, 0.08); color: #38bdf8; font-size: 0.8rem;">
<i class="fas fa-info-circle mr-1 text-info"></i> <strong>Akun Login Sementara (Dev Mode):</strong><br>
Email: <code>[email protected]</code> | Pass: <code>RSSAjaya2026</code>
</div> -->
<?php if (!empty($loginError)): ?>
<div class="alert alert-danger text-left small mb-4" role="alert">
<i class="fas fa-exclamation-triangle mr-2"></i> <?= htmlspecialchars($loginError) ?>
</div>
<?php endif; ?>
<form method="POST" action="forensic_viewer.php">
<input type="hidden" name="action" value="keycloak_login">
<div class="form-group text-left mb-3">
<label class="small text-muted font-weight-bold">Username / Email Keycloak</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="fas fa-user"></i></span>
</div>
<input type="text" name="username" class="form-control form-control-dark" placeholder="Username / Email (cth: [email protected])" required autofocus value="<?= htmlspecialchars($_POST['username'] ?? '') ?>">
</div>
</div>
<div class="form-group text-left mb-3">
<label class="small text-muted font-weight-bold">Password</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="fas fa-lock"></i></span>
</div>
<input type="password" name="password" id="forensic_login_password" class="form-control form-control-dark" placeholder="Masukkan Password" required>
<div class="input-group-append">
<button class="btn btn-dark border-secondary text-muted" type="button" onclick="toggleForensicPassword('forensic_login_password', this)" title="Tampilkan / Sembunyikan Password">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
</div>
<?php if (!$isKcEnvConfigured): ?>
<!-- Advanced Keycloak SSO Config (Collapsible) -->
<div class="text-left mb-4">
<a class="small text-info text-decoration-none" data-toggle="collapse" href="#kcConfigCollapse" role="button" aria-expanded="false">
<i class="fas fa-cog mr-1"></i> Pengaturan Server Keycloak (Advanced)
</a>
<div class="collapse mt-2" id="kcConfigCollapse">
<div class="config-accordion">
<div class="form-group mb-2">
<label class="small text-muted mb-1">Keycloak Server Base URL</label>
<input type="text" name="kc_server" class="form-control form-control-dark form-control-sm" value="<?= htmlspecialchars($_POST['kc_server'] ?? $defaultKcServer) ?>">
</div>
<div class="form-row">
<div class="form-group col-6 mb-2">
<label class="small text-muted mb-1">Realm</label>
<input type="text" name="kc_realm" class="form-control form-control-dark form-control-sm" value="<?= htmlspecialchars($_POST['kc_realm'] ?? $defaultKcRealm) ?>">
</div>
<div class="form-group col-6 mb-2">
<label class="small text-muted mb-1">Client ID</label>
<input type="text" name="kc_client_id" class="form-control form-control-dark form-control-sm" value="<?= htmlspecialchars($_POST['kc_client_id'] ?? $defaultKcClient) ?>">
</div>
</div>
<div class="form-group mb-0">
<label class="small text-muted mb-1">Client Secret (Opsional)</label>
<input type="password" name="kc_client_secret" class="form-control form-control-dark form-control-sm" value="<?= htmlspecialchars($_POST['kc_client_secret'] ?? $defaultKcSecret) ?>">
</div>
</div>
</div>
</div>
<?php endif; ?>
<button type="submit" class="btn btn-teal btn-block mb-3">
<i class="fas fa-sign-in-alt mr-2"></i> Masuk (Keycloak SSO / Local)
</button>
</form>
<!-- Secret Hotkey Badge Hint -->
<div class="mb-3">
<span class="badge badge-dark border border-secondary text-info px-3 py-2" style="font-size: 0.82rem; cursor: pointer;" onclick="alert('🔑 Secret Key Shortcuts:\n\n1. [Ctrl + Alt + F] atau [Ctrl + Shift + F] : Langsung menuju Forensic Inspector dari halaman manapun\n2. [Ctrl + Shift + K] : Fokus cepat ke kolom Username SSO\n3. Ketik kata \'forensic\' atau \'inspector\' kapan saja di keyboard.');">
<i class="fas fa-keyboard mr-1"></i> Secret Shortcut: <kbd class="bg-secondary text-white">Ctrl + Alt + F</kbd> / Ketik <code>forensic</code>
</span>
</div>
<div class="text-center mt-2">
<a href="index.php" class="small text-muted"><i class="fas fa-arrow-left mr-1"></i> Kembali ke SIMRS Utama</a>
</div>
</div>
<script src="library/jquery-3.3.1/jquery-3.3.1.min.js"></script>
<script src="library/bootstrap-4.1.3/js/bootstrap.bundle.min.js"></script>
<script>
function toggleForensicPassword(inputId, btn) {
var field = document.getElementById(inputId);
if (!field) return;
var icon = btn ? btn.querySelector('i') : null;
if (field.type === 'password') {
field.type = 'text';
if (icon) icon.className = 'fas fa-eye-slash text-info';
} else {
field.type = 'password';
if (icon) icon.className = 'fas fa-eye';
}
}
(function() {
// Secret Keyboard Shortcut Handler
document.addEventListener('keydown', function(e) {
// Ctrl + Alt + F atau Ctrl + Shift + F
if ((e.ctrlKey && e.altKey && (e.key === 'f' || e.key === 'F')) ||
(e.ctrlKey && e.shiftKey && (e.key === 'F' || e.key === 'f'))) {
e.preventDefault();
window.location.href = 'forensic_viewer.php';
}
// Ctrl + Shift + K -> Fokus ke input username
if (e.ctrlKey && e.shiftKey && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
var userInput = document.querySelector('input[name="username"]');
if (userInput) userInput.focus();
}
});
// Secret Typing Keyword Listener: Ketik "forensic" atau "inspector"
var secretBuf = "";
var secretTimer = null;
document.addEventListener('keypress', function(e) {
var tag = (e.target.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || e.target.isContentEditable) return;
secretBuf += String.fromCharCode(e.which).toLowerCase();
clearTimeout(secretTimer);
secretTimer = setTimeout(function() { secretBuf = ""; }, 2500);
if (secretBuf.indexOf("forensic") !== -1 || secretBuf.indexOf("inspector") !== -1) {
secretBuf = "";
window.location.href = "forensic_viewer.php";
}
});
})();
</script>
</body>
</html>
<?php
exit;
endif;
// Action: Manual Run Retention Job
$actionMessage = '';
if (isset($_POST['action']) && $_POST['action'] === 'run_retention') {
require_once __DIR__ . '/core/LogRetentionManager.php';
$retention = new LogRetentionManager();
$res = $retention->run();
$actionMessage = 'Job retention & backup berhasil dijalankan. (Forensic: ' . ($res['forensic'] ?? 0) . ' files, Activity: ' . ($res['activity'] ?? 0) . ' files archived)';
}
// Action: Clear Brute Force Lockout
if (isset($_POST['action']) && $_POST['action'] === 'clear_bf' && !empty($_POST['target_ip'])) {
if (isset($forensic)) {
$forensic->clearBruteForce($_POST['target_ip']);
$actionMessage = 'Lockout IP ' . htmlspecialchars($_POST['target_ip']) . ' berhasil dibersihkan.';
}
}
// Filters
$filterDate = $_GET['date'] ?? '';
$filterChannel = $_GET['channel'] ?? 'all';
$filterLevel = $_GET['level'] ?? 'all';
$filterCorrelation = $_GET['correlation'] ?? '';
$filterSearch = $_GET['search'] ?? '';
$filterTimeStart = $_GET['time_start'] ?? '';
$filterTimeEnd = $_GET['time_end'] ?? '';
$filterSource = $_GET['source'] ?? 'auto';
// Sinkronisasi Tanggal Otomatis jika Memilih File Berkas Arsip ZIP Spesifik
$isSpecificZip = (strpos($filterSource, '.zip') !== false);
if ($isSpecificZip) {
if (preg_match('/(20\d{2}-\d{2})/', $filterSource, $zipYmMatch)) {
$zipYm = $zipYmMatch[1];
if (!isset($_GET['date']) || strpos($_GET['date'], $zipYm) === false) {
$filterDate = $zipYm . '-01';
}
}
}
if (empty($filterDate)) {
$filterDate = date('Y-m-d');
}
// Scan Backup Archives terlebih dahulu
$backupDir = _DOCROOT_ . 'logs/backup/';
$backups = [];
if (is_dir($backupDir)) {
foreach (glob($backupDir . '*.zip') as $zipFile) {
$backups[] = [
'filename' => basename($zipFile),
'size_mb' => round(filesize($zipFile) / 1024 / 1024, 2),
'mtime' => date('Y-m-d H:i:s', filemtime($zipFile)),
];
}
// Urutkan arsip ZIP terbaru di atas (DESC by filename & date)
usort($backups, function ($a, $b) {
return strcmp($b['filename'], $a['filename']);
});
}
// Helper: Scan log files for selected date across all log directories
$logsRoot = _DOCROOT_ . 'logs/';
$year = date('Y', strtotime($filterDate));
$month = date('m', strtotime($filterDate));
$day = date('d', strtotime($filterDate));
$dmY = date('d-m-Y', strtotime($filterDate));
$logs = [];
$stats = [
'total' => 0,
'login_success' => 0,
'login_failed' => 0,
'session_expired' => 0,
'errors' => 0,
'warnings' => 0,
'performance_count' => 0,
'avg_response_ms' => 0,
];
$totalRespMs = 0;
$extractLogMetaData = function(&$entry, $rawLine) {
if (!isset($entry['context']) || !is_array($entry['context'])) {
$entry['context'] = [];
}
$msg = $entry['message'] ?? $rawLine;
$searchTarget = $rawLine . ' ' . $msg . ' ' . json_encode($entry['context']);
// 1. EXTRACTION: IP ADDRESS
$extractedIp = $entry['context']['ip_address'] ?? ($entry['context']['ip'] ?? ($entry['context']['remote_addr'] ?? ($entry['ip_address'] ?? ($entry['ip'] ?? ''))));
if (empty($extractedIp) || $extractedIp === '-') {
if (preg_match_all('/\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/', $searchTarget, $ipMatches)) {
$foundIps = array_unique($ipMatches[0]);
$bestIp = '';
foreach ($foundIps as $ip) {
if ($ip !== '127.0.0.1' && $ip !== '0.0.0.0') {
$bestIp = $ip;
break;
}
}
$extractedIp = !empty($bestIp) ? $bestIp : reset($foundIps);
}
}
if (!empty($extractedIp)) {
$entry['context']['ip_address'] = $extractedIp;
}
// 2. EXTRACTION: USER NIP / USERNAME / PETUGAS
$extractedNip = $entry['context']['user_nip'] ?? ($entry['context']['user'] ?? ($entry['context']['nip'] ?? ($entry['context']['username'] ?? ($entry['user_nip'] ?? ($entry['nip'] ?? '')))));
if (empty($extractedNip) || $extractedNip === '-') {
if (preg_match('/\["([^"]+)"\s*,\s*"([^"]*http[^"]*)"/i', $searchTarget, $mNameUrl)) {
$extractedNip = trim($mNameUrl[1]);
if (empty($entry['context']['request_uri'])) {
$entry['context']['request_uri'] = trim($mNameUrl[2]);
}
} elseif (preg_match('/(?:NIP|User|Petugas|Akun|oleh)[:\s]+([A-Za-z0-9_\-\.\,\@\s]{3,40})/i', $searchTarget, $mNip)) {
$extractedNip = trim($mNip[1]);
}
}
if (!empty($extractedNip)) {
$entry['context']['user_nip'] = $extractedNip;
}
// 3. EXTRACTION: REQUEST URI / URL
$extractedUri = $entry['context']['request_uri'] ?? ($entry['context']['uri'] ?? ($entry['context']['url'] ?? ($entry['request_uri'] ?? ($entry['uri'] ?? ''))));
if (empty($extractedUri) || $extractedUri === '-') {
if (preg_match('/https?:\/\/[^\s"\'\]]+/', $searchTarget, $mUrl)) {
$extractedUri = $mUrl[0];
} elseif (preg_match('/\s(\/[a-zA-Z0-9_\-\/\.]+\.php[^\s"\'\]]*)/', $searchTarget, $mUri)) {
$extractedUri = $mUri[1];
}
}
if (!empty($extractedUri)) {
$entry['context']['request_uri'] = $extractedUri;
}
};
$processLogLine = function ($line, $sourceChannel = 'access') use (&$logs, &$stats, &$totalRespMs, &$respCount, $filterLevel, $filterTimeStart, $filterTimeEnd, $filterCorrelation, $filterSearch, $filterChannel, $extractLogMetaData, $filterDate) {
$line = trim($line);
if (empty($line)) return;
static $lineSeq = 0;
$lineSeq++;
$entry = json_decode($line, true);
if (!$entry || !is_array($entry)) {
// Parsing log non-JSON (Access, Activity, Retention log)
$defaultTs = !empty($filterDate) ? $filterDate . ' 00:00:00' : date('Y-m-d H:i:s');
$timestamp = '';
if (preg_match('/\[(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]/', $line, $m)) {
$timestamp = str_replace('T', ' ', $m[1]);
} elseif (preg_match('/(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?)/', $line, $m)) {
$timestamp = str_replace('T', ' ', $m[1]);
} elseif (preg_match('/(\d{2}-\d{2}-\d{4}[ T]\d{2}:\d{2}:\d{2})/', $line, $m)) {
$timestamp = date('Y-m-d H:i:s', strtotime($m[1]));
} elseif (preg_match('/(\d{4}\/\d{2}\/\d{2}[ T]\d{2}:\d{2}:\d{2})/', $line, $m)) {
$timestamp = date('Y-m-d H:i:s', strtotime(str_replace('/', '-', $m[1])));
}
if (empty($timestamp)) {
$timestamp = $defaultTs;
} else {
if (preg_match('/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/', $timestamp, $tmClean)) {
$timestamp = $tmClean[1];
}
}
$entry = [
'timestamp' => $timestamp,
'channel' => $sourceChannel ?: 'access',
'level' => 'INFO',
'message' => $line,
'correlation_id' => '-',
'context' => [
'raw_log' => $line
]
];
} else {
if (empty($entry['timestamp'])) {
$tsVal = $entry['created_at'] ?? ($entry['date'] ?? ($entry['time'] ?? ''));
if (!empty($tsVal)) {
$entry['timestamp'] = date('Y-m-d H:i:s', strtotime($tsVal));
} else {
if (preg_match('/(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})/', $line, $m)) {
$entry['timestamp'] = str_replace('T', ' ', $m[1]);
} else {
$entry['timestamp'] = !empty($filterDate) ? $filterDate . ' 00:00:00' : date('Y-m-d H:i:s');
}
}
}
}
$entry['_seq'] = $lineSeq;
// Ekstraksi metadata pintar (IP Address, User NIP, URI)
$extractLogMetaData($entry, $line);
$ch = $entry['channel'] ?? $sourceChannel;
if ($filterChannel !== 'all' && strtolower($ch) !== strtolower($filterChannel)) {
return;
}
$lvl = strtoupper($entry['level'] ?? 'INFO');
if ($filterLevel !== 'all' && $lvl !== strtoupper($filterLevel)) {
return;
}
if ($filterTimeStart !== '' || $filterTimeEnd !== '') {
$entryTime = '';
if (isset($entry['timestamp'])) {
if (preg_match('/[T ](\d{2}:\d{2})/', $entry['timestamp'], $timeMatch)) {
$entryTime = $timeMatch[1];
} else {
$entryTime = date('H:i', strtotime($entry['timestamp']));
}
}
if ($filterTimeStart !== '' && $entryTime !== '' && $entryTime < $filterTimeStart) {
return;
}
if ($filterTimeEnd !== '' && $entryTime !== '' && $entryTime > $filterTimeEnd) {
return;
}
}
if ($filterCorrelation !== '' && ($entry['correlation_id'] ?? '') !== $filterCorrelation) {
return;
}
if ($filterSearch !== '') {
$searchTarget = ($entry['message'] ?? '') . ' ' .
($entry['channel'] ?? '') . ' ' .
($entry['level'] ?? '') . ' ' .
($entry['correlation_id'] ?? '') . ' ' .
($entry['context']['ip_address'] ?? '') . ' ' .
($entry['context']['user_nip'] ?? '') . ' ' .
($entry['context']['request_uri'] ?? '') . ' ' .
json_encode($entry['context'] ?? [], JSON_UNESCAPED_SLASHES);
if (stripos($searchTarget, $filterSearch) === false) {
return;
}
}
$stats['total']++;
if ($lvl === 'ERROR' || $lvl === 'CRITICAL') $stats['errors']++;
if ($lvl === 'WARNING') $stats['warnings']++;
$msg = $entry['message'] ?? '';
if (stripos($msg, 'login_success') !== false || stripos($msg, 'login ok') !== false || stripos($msg, 'login berhasil') !== false) $stats['login_success']++;
if (stripos($msg, 'login_failed') !== false || stripos($msg, 'gagal login') !== false) $stats['login_failed']++;
if (stripos($msg, 'session_expired') !== false || stripos($msg, 'session habis') !== false) $stats['session_expired']++;
if (strtolower($ch) === 'performance') {
$stats['performance_count']++;
if (isset($entry['context']['total_duration_ms'])) {
$totalRespMs += $entry['context']['total_duration_ms'];
$respCount++;
}
}
$logs[] = $entry;
};
// 1. Baca Forensic Logs (/app/logs/forensic/YYYY/MM/*.log)
if ($filterSource === 'auto' || $filterSource === 'active') {
$forensicDir = $logsRoot . 'forensic/' . $year . '/' . $month . '/';
if (is_dir($forensicDir)) {
foreach (glob($forensicDir . '*.log') as $fPath) {
$fName = basename($fPath);
if (strpos($fName, $filterDate) !== false) {
$chName = explode('_', $fName)[0] ?? 'forensic';
$handle = @fopen($fPath, 'r');
if ($handle) {
while (($line = fgets($handle)) !== false) {
$processLogLine($line, $chName);
}
fclose($handle);
}
}
}
}
}
// 2. Baca Activity Logs (/app/logs/activity/YYYY/MM/DD-MM-YYYY.log)
if ($filterSource === 'auto' || $filterSource === 'active') {
$activityPath = $logsRoot . 'activity/' . $year . '/' . $month . '/' . $dmY . '.log';
if (file_exists($activityPath)) {
$handle = @fopen($activityPath, 'r');
if ($handle) {
while (($line = fgets($handle)) !== false) {
$processLogLine($line, 'activity');
}
fclose($handle);
}
}
}
// 3. Baca Access Logs (/app/logs/access/YYYY/MM/DD/*.log)
if ($filterSource === 'auto' || $filterSource === 'active') {
$accessDir = $logsRoot . 'access/' . $year . '/' . $month . '/' . $day . '/';
if (is_dir($accessDir)) {
foreach (glob($accessDir . '*.log') as $aPath) {
$aName = basename($aPath, '.log');
$handle = @fopen($aPath, 'r');
if ($handle) {
while (($line = fgets($handle)) !== false) {
$processLogLine($line, 'access');
}
fclose($handle);
}
}
}
}
// 4. Baca System Retention Log (/app/logs/retention.log)
if ($filterSource === 'auto' || $filterSource === 'active') {
$retentionPath = $logsRoot . 'retention.log';
if (file_exists($retentionPath)) {
$handle = @fopen($retentionPath, 'r');
if ($handle) {
while (($line = fgets($handle)) !== false) {
if (empty($filterDate) || strpos($line, $filterDate) !== false) {
$processLogLine($line, 'system');
}
}
fclose($handle);
}
}
}
// 5. Baca ZIP Archives (/app/logs/backup/*.zip)
$zipFilesToProcess = [];
if (is_dir($backupDir)) {
if (strpos($filterSource, '.zip') !== false && file_exists($backupDir . $filterSource)) {
$zipFilesToProcess[] = $backupDir . $filterSource;
} elseif ($filterSource === 'all_zip' || ($filterSource === 'auto' && empty($logs))) {
foreach (glob($backupDir . '*.zip') as $zFile) {
if ($filterSource === 'auto' && !empty($filterDate)) {
$ymMonth = date('Y-m', strtotime($filterDate));
if (strpos(basename($zFile), $ymMonth) !== false) {
$zipFilesToProcess[] = $zFile;
}
} else {
$zipFilesToProcess[] = $zFile;
}
}
}
}
if (!empty($zipFilesToProcess) && class_exists('ZipArchive')) {
$zip = new ZipArchive();
foreach ($zipFilesToProcess as $zPath) {
if ($zip->open($zPath) === true) {
for ($i = 0; $i < $zip->numFiles; $i++) {
$entryName = $zip->getNameIndex($i);
if (!empty($filterDate) && $filterSource === 'auto') {
if (strpos($entryName, $filterDate) === false &&
strpos($entryName, $dmY) === false &&
strpos($entryName, '/' . $day . '/') === false) {
continue;
}
}
$stream = $zip->getStream($entryName);
if ($stream) {
$ch = 'archive';
if (strpos($entryName, 'session') !== false) $ch = 'session';
elseif (strpos($entryName, 'auth') !== false) $ch = 'auth';
elseif (strpos($entryName, 'error') !== false) $ch = 'error';
elseif (strpos($entryName, 'performance') !== false) $ch = 'performance';
elseif (strpos($entryName, 'security') !== false) $ch = 'security';
elseif (strpos($entryName, 'access') !== false) $ch = 'access';
elseif (strpos($entryName, 'activity') !== false) $ch = 'activity';
while (($line = fgets($stream)) !== false) {
$processLogLine($line, $ch);
}
fclose($stream);
}
}
$zip->close();
}
}
}
if ($respCount > 0) {
$stats['avg_response_ms'] = round($totalRespMs / $respCount, 2);
}
// Sort logs strictly descending by timestamp (newest first), with sequence index as secondary tie-breaker
usort($logs, function ($a, $b) {
$tsA = strtotime($a['timestamp'] ?? '1970-01-01');
$tsB = strtotime($b['timestamp'] ?? '1970-01-01');
if ($tsA !== $tsB) {
return $tsB <=> $tsA; // Descending: newest log entry at the top
}
return ($b['_seq'] ?? 0) <=> ($a['_seq'] ?? 0); // Newest line in file on top
});
// Pagination Configuration & Lazy Load / Infinite Scroll (Limit 100 per page default)
$perPage = isset($_GET['limit']) ? (int)$_GET['limit'] : 100;
if (!in_array($perPage, [50, 100, 250, 500, 1000])) {
$perPage = 100;
}
$totalLogs = count($logs);
$totalPages = (int)ceil($totalLogs / $perPage);
if ($totalPages < 1) $totalPages = 1;
$currentPage = isset($_GET['page']) ? (int)$_GET['page'] : 1;
if ($currentPage < 1) $currentPage = 1;
if ($currentPage > $totalPages) $currentPage = $totalPages;
$offset = ($currentPage - 1) * $perPage;
$pagedLogs = array_slice($logs, $offset, $perPage);
// Helper function to render a single log row cleanly
$renderLogRow = function ($l, $idx) {
$lvl = $l['level'] ?? 'INFO';
$ts = $l['timestamp'] ?? '';
$timeStr = !empty($ts) ? date('H:i:s', strtotime($ts)) : '-';
$ctx = $l['context'] ?? [];
$rowId = 'detail_' . $idx;
$msg = $l['message'] ?? '';
$isLong = mb_strlen($msg) > 130;
$msgShort = $isLong ? mb_substr($msg, 0, 130) : $msg;
$msgId = 'msg_' . $idx;
?>
<tr>
<td><small class="text-muted"><?= date('Y-m-d', strtotime($ts)) ?></small><br><strong class="text-light"><?= $timeStr ?></strong></td>
<td><span class="badge badge-<?= $lvl ?> px-2 py-1"><?= $lvl ?></span></td>
<td><span class="badge badge-dark border border-secondary text-info text-truncate d-inline-block" style="max-width: 85px;" title="<?= htmlspecialchars($l['channel'] ?? '-') ?>"><?= htmlspecialchars($l['channel'] ?? '-') ?></span></td>
<td><code class="text-truncate d-inline-block" style="max-width: 100px;" title="<?= htmlspecialchars($ctx['ip_address'] ?? '-') ?>"><?= htmlspecialchars($ctx['ip_address'] ?? '-') ?></code></td>
<td><span class="text-warning text-truncate d-inline-block" style="max-width: 100px;" title="<?= htmlspecialchars($ctx['user_nip'] ?? '-') ?>"><?= htmlspecialchars($ctx['user_nip'] ?? '-') ?></span></td>
<td>
<div class="msg-truncated-box" id="<?= $msgId ?>">
<?php if ($isLong): ?>
<div class="msg-preview" id="<?= $msgId ?>_preview">
<span class="log-msg-text"><?= htmlspecialchars($msgShort) ?>...</span>
<a href="javascript:void(0)" onclick="document.getElementById('<?= $msgId ?>_preview').style.display='none'; document.getElementById('<?= $msgId ?>_full').style.display='block';" class="badge badge-info px-2 py-1 ml-1" style="font-weight: normal; text-decoration: none;">
<i class="fas fa-eye mr-1"></i> Detail
</a>
</div>
<div class="msg-full" id="<?= $msgId ?>_full" style="display: none;">
<span class="log-msg-text text-light"><?= htmlspecialchars($msg) ?></span>
<a href="javascript:void(0)" onclick="document.getElementById('<?= $msgId ?>_full').style.display='none'; document.getElementById('<?= $msgId ?>_preview').style.display='block';" class="badge badge-dark border border-secondary text-muted px-2 py-1 ml-1" style="font-weight: normal; text-decoration: none;">
<i class="fas fa-compress-alt mr-1"></i> Ringkas
</a>
</div>
<?php else: ?>
<span class="log-msg-text"><?= htmlspecialchars($msg) ?></span>
<?php endif; ?>
</div>
<?php if (!empty($ctx['request_uri'])): ?>
<div class="small text-muted text-truncate mt-1" style="max-width: 100%;" title="<?= htmlspecialchars($ctx['request_uri']) ?>">
<i class="fas fa-link mr-1"></i><?= htmlspecialchars($ctx['request_uri']) ?>
</div>
<?php endif; ?>
</td>
<td>
<button class="btn btn-sm btn-outline-info py-0 px-2" type="button" data-toggle="collapse" data-target="#<?= $rowId ?>" title="Lihat JSON mentah">
<i class="fas fa-code"></i>
</button>
</td>
</tr>
<tr class="collapse" id="<?= $rowId ?>">
<td colspan="7" class="p-3" style="background: #0b1120;">
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="small font-weight-bold text-info"><i class="fas fa-file-code mr-1"></i> Data Log JSON Structure (Row #<?= $idx + 1 ?>)</span>
<button type="button" class="btn btn-xs btn-outline-secondary py-0 px-2 text-muted" onclick="copyTextToClipboard(this.closest('td').querySelector('code').innerText, this);" title="Salin JSON">
<i class="fas fa-copy mr-1"></i> Copy JSON
</button>
</div>
<div class="json-box">
<pre class="m-0 text-info"><code><?= htmlspecialchars(json_encode($l, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) ?></code></pre>
</div>
</td>
</tr>
<?php
};
// Handle AJAX Request for Infinite Scroll / Lazy Load
if (isset($_GET['ajax']) && $_GET['ajax'] == '1') {
ob_start();
foreach ($pagedLogs as $i => $l) {
$renderLogRow($l, $offset + $i);
}
$html = ob_get_clean();
header('Content-Type: application/json');
echo json_encode([
'html' => $html,
'currentPage' => $currentPage,
'totalPages' => $totalPages,
'hasNext' => $currentPage < $totalPages,
'nextPage' => $currentPage + 1,
'totalLogs' => $totalLogs,
'countLoaded' => count($pagedLogs),
]);
exit;
}
// Scan Brute Force cache
$bfDir = $logsRoot . 'forensic/cache/';
$lockedIPs = [];
if (is_dir($bfDir)) {
foreach (glob($bfDir . 'bf_*.json') as $bfFile) {
$bfData = json_decode(@file_get_contents($bfFile), true);
if ($bfData && isset($bfData['lockout_until']) && $bfData['lockout_until'] > time()) {
$lockedIPs[] = $bfData;
}
}
}
// Export CSV / JSON action
if (isset($_GET['export'])) {
if ($_GET['export'] === 'json') {
header('Content-Type: application/json');
header('Content-Disposition: attachment; filename="forensic_log_' . $filterDate . '.json"');
echo json_encode($logs, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
exit;
} elseif ($_GET['export'] === 'csv') {
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename="forensic_log_' . $filterDate . '.csv"');
$out = fopen('php://output', 'w');
fputcsv($out, ['Timestamp', 'Correlation ID', 'Channel', 'Level', 'Message', 'IP', 'NIP', 'URI']);
foreach ($logs as $l) {
fputcsv($out, [
$l['timestamp'] ?? '',
$l['correlation_id'] ?? '',
$l['channel'] ?? '',
$l['level'] ?? '',
$l['message'] ?? '',
$l['context']['ip_address'] ?? '',
$l['context']['user_nip'] ?? '',
$l['context']['request_uri'] ?? '',
]);
}
fclose($out);
exit;
}
}
$currentUser = $_SESSION['forensic_user']['username'] ?? ($_SESSION['SES_REG'] ?? 'Admin');
?>
<!DOCTYPE html>
<html lang="id">
<head>
<base href="//<?= $_SERVER['HTTP_HOST'] ?>/">
<?php if ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https')): ?>
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">
<?php endif; ?>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SIMRS Forensic Inspector</title>
<link rel="stylesheet" href="library/bootstrap-4.1.3/css/bootstrap.min.css">
<link rel="stylesheet" href="library/fontawesome-free-5.10.2-web/css/all.min.css">
<style>
body { background-color: #0f172a; color: #e2e8f0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
:root { color-scheme: dark; }
input[type="date"], input[type="time"], input[type="datetime-local"], select {
color-scheme: dark;
}
::-webkit-calendar-picker-indicator {
filter: invert(0.85);
cursor: pointer;
}
select option {
background-color: #0f172a;
color: #f8fafc;
}
.navbar-custom { background: linear-gradient(135deg, #1e293b, #0f172a); border-bottom: 1px solid #334155; }
.card-stat { background: #1e293b; border: 1px solid #334155; border-radius: 10px; transition: transform 0.2s; }
.card-stat:hover { transform: translateY(-3px); }
.card-header-custom { background: #1e293b; border-bottom: 1px solid #334155; color: #38bdf8; font-weight: 600; }
.table-custom { background: #1e293b; color: #cbd5e1; font-size: 0.88rem; table-layout: fixed; width: 100%; }
.table-custom th { background: #0f172a; color: #94a3b8; border-color: #334155; }
.table-custom td { border-color: #1e293b; vertical-align: top; word-break: break-word; overflow-wrap: break-word; }
.table-custom tr:hover { background-color: #334155; }
.log-msg-text { word-break: break-all; white-space: pre-wrap; font-family: 'Consolas', 'Courier New', monospace; font-size: 0.84rem; }
.msg-truncated-box { max-width: 100%; display: block; }
.badge-CRITICAL { background-color: #ef4444; color: #fff; font-weight: 700; }
.badge-ERROR { background-color: #f97316; color: #fff; }
.badge-WARNING { background-color: #eab308; color: #000; }
.badge-INFO { background-color: #06b6d4; color: #fff; }
.badge-DEBUG { background-color: #64748b; color: #fff; }
.json-box {
background: #090d16;
color: #38bdf8;
padding: 14px 16px;
border-radius: 8px;
font-family: 'Consolas', 'Fira Code', 'Courier New', monospace;
font-size: 0.83rem;
max-height: 350px;
overflow-y: auto;
overflow-x: hidden;
border: 1px solid #334155;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.5);
}
.json-box pre, .json-box code {
white-space: pre-wrap !important;
word-break: break-all !important;
overflow-wrap: break-word !important;
margin: 0;
color: #38bdf8;
font-family: inherit;
font-size: inherit;
}
.btn-teal { background-color: #0d9488; color: #fff; border: none; }
.btn-teal:hover { background-color: #0f766e; color: #fff; }
.form-control-dark { background-color: #0f172a; border: 1px solid #334155; color: #f8fafc; }
.form-control-dark:focus { background-color: #1e293b; color: #fff; border-color: #38bdf8; box-shadow: none; }
.nav-tabs-custom { border-bottom: 1px solid #334155; }
.nav-tabs-custom .nav-link { color: #94a3b8; border: 1px solid transparent; border-top-left-radius: 8px; border-top-right-radius: 8px; font-weight: 600; padding: 0.75rem 1.25rem; }
.nav-tabs-custom .nav-link:hover { color: #f8fafc; border-color: #334155; }
.nav-tabs-custom .nav-link.active { background-color: #1e293b; border-color: #334155 #334155 #1e293b; color: #38bdf8 !important; }
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-custom navbar-dark py-3">
<div class="container-fluid">
<a class="navbar-brand d-flex align-items-center" href="forensic_viewer.php">
<i class="fas fa-search-location text-info fa-2x mr-3"></i>
<div>
<strong style="font-size: 1.25rem; letter-spacing: 0.5px;">SIMRS Forensic Inspector</strong>
<div style="font-size: 0.75rem; color: #94a3b8;">System Diagnostics, Audit & Security Monitor</div>
</div>
</a>
<div class="ml-auto d-flex align-items-center">
<?php if ($isSsoLoggedIn): ?>
<span class="badge badge-info px-3 py-2 mr-2" style="border: 1px solid #06b6d4;">
<i class="fas fa-user-circle mr-1"></i> SSO: <?= htmlspecialchars($currentUser) ?>
</span>
<a href="forensic_viewer.php?action=logout" class="btn btn-sm btn-outline-danger mr-2" title="Keluar Akun Keycloak"><i class="fas fa-power-off mr-1"></i> Logout SSO</a>
<?php else: ?>
<a href="forensic_viewer.php?show_login=1" class="btn btn-sm btn-outline-info mr-2" title="Masuk via Keycloak SSO"><i class="fas fa-key mr-1"></i> Login SSO Keycloak</a>
<?php endif; ?>
<a href="index.php" class="btn btn-sm btn-outline-light"><i class="fas fa-home mr-1"></i> Ke SIMRS</a>
</div>
</div>
</nav>
<div class="container-fluid py-4">
<?php if (!empty($actionMessage)): ?>
<div class="alert alert-info alert-dismissible fade show" role="alert">
<i class="fas fa-info-circle mr-2"></i> <?= htmlspecialchars($actionMessage) ?>
<button type="button" class="close" data-dismiss="alert"><span>&times;</span></button>
</div>
<?php endif; ?>
<!-- STATISTIK SUMMARY -->
<div class="row mb-4">
<div class="col-md-2">
<div class="card card-stat p-3 text-center">
<div class="text-muted small text-uppercase">Total Event</div>
<div class="h2 mb-0 font-weight-bold text-light"><?= number_format($stats['total']) ?></div>
<div class="small text-info mt-1"><i class="far fa-calendar-alt mr-1"></i><?= $filterDate ?></div>
</div>
</div>
<div class="col-md-2">
<div class="card card-stat p-3 text-center">
<div class="text-muted small text-uppercase">Login Sukses</div>
<div class="h2 mb-0 font-weight-bold text-success"><?= number_format($stats['login_success']) ?></div>
<div class="small text-muted mt-1">Gagal: <span class="text-danger"><?= $stats['login_failed'] ?></span></div>
</div>
</div>
<div class="col-md-2">
<div class="card card-stat p-3 text-center">
<div class="text-muted small text-uppercase">Session Expired</div>
<div class="h2 mb-0 font-weight-bold text-warning"><?= number_format($stats['session_expired']) ?></div>
<div class="small text-muted mt-1">Navigasi Terhalang</div>
</div>
</div>
<div class="col-md-2">
<div class="card card-stat p-3 text-center">
<div class="text-muted small text-uppercase">Error & Critical</div>
<div class="h2 mb-0 font-weight-bold text-danger"><?= number_format($stats['errors']) ?></div>
<div class="small text-muted mt-1">Warning: <?= $stats['warnings'] ?></div>
</div>
</div>
<div class="col-md-2">
<div class="card card-stat p-3 text-center">
<div class="text-muted small text-uppercase">Rata² Response</div>
<div class="h2 mb-0 font-weight-bold text-info"><?= $stats['avg_response_ms'] ?> <small style="font-size: 0.9rem;">ms</small></div>
<div class="small text-muted mt-1"><?= $stats['performance_count'] ?> Request</div>
</div>
</div>
<div class="col-md-2">
<div class="card card-stat p-3 text-center">
<div class="text-muted small text-uppercase">Brute Force Locked</div>
<div class="h2 mb-0 font-weight-bold text-danger"><?= count($lockedIPs) ?></div>
<div class="small text-muted mt-1">IP Terblokir</div>
</div>
</div>
</div>
<!-- BRUTE FORCE LOCKOUT PANEL (jika ada IP terblokir) -->
<?php if (!empty($lockedIPs)): ?>
<div class="card card-stat mb-4 border-danger">
<div class="card-header bg-danger text-white font-weight-bold">
<i class="fas fa-user-shield mr-2"></i> Peringatan Keamanan: Perangkat / IP Terblokir (Brute Force Detected)
</div>
<div class="card-body p-0">
<table class="table table-custom mb-0">
<thead>
<tr>
<th>IP Address</th>
<th>Total Gagal</th>
<th>Sisa Waktu Lockout</th>
<th>Aksi Unblock</th>
</tr>
</thead>
<tbody>
<?php foreach ($lockedIPs as $b): ?>
<tr>
<td><strong class="text-warning"><?= htmlspecialchars($b['ip']) ?></strong></td>
<td><?= count($b['attempts'] ?? []) ?>x Percobaan Gagal</td>
<td>
<?php
$remain = max(0, $b['lockout_until'] - time());
echo ceil($remain / 60) . ' Menit (' . date('H:i:s', $b['lockout_until']) . ')';
?>
</td>
<td>
<form method="POST" style="display:inline;">
<input type="hidden" name="action" value="clear_bf">
<input type="hidden" name="target_ip" value="<?= htmlspecialchars($b['ip']) ?>">
<button type="submit" class="btn btn-sm btn-outline-success"><i class="fas fa-unlock mr-1"></i> Buka Blokir IP</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- FILTER & ACTION TOOLBAR -->
<div class="card card-stat mb-4">
<div class="card-body py-3">
<form method="GET" action="forensic_viewer.php" class="form-row align-items-end mb-0">
<div class="col-xl-2 col-lg-3 col-md-4 col-sm-6 mb-2 mb-xl-0">
<label class="small text-muted mb-1 font-weight-bold"><i class="fas fa-database mr-1 text-info"></i> Sumber Log</label>
<select name="source" class="form-control form-control-dark w-100" title="Sumber Data Log (Aktif / Arsip ZIP)" onchange="this.form.submit();">
<option value="auto" <?= $filterSource === 'auto' ? 'selected' : '' ?>>⚡ Auto (Aktif + ZIP)</option>
<option value="active" <?= $filterSource === 'active' ? 'selected' : '' ?>>📁 Log Aktif (Forensic + Access + Activity)</option>
<option value="all_zip" <?= $filterSource === 'all_zip' ? 'selected' : '' ?>>📦 Semua Arsip ZIP Backup</option>
<?php if (!empty($backups)): ?>
<optgroup label="File Arsip ZIP:">
<?php foreach ($backups as $bk): ?>
<option value="<?= htmlspecialchars($bk['filename']) ?>" <?= $filterSource === $bk['filename'] ? 'selected' : '' ?>>
📦 <?= htmlspecialchars($bk['filename']) ?>
</option>
<?php endforeach; ?>
</optgroup>
<?php endif; ?>
</select>
</div>
<div class="col-xl-2 col-lg-3 col-md-4 col-sm-6 mb-2 mb-xl-0">
<label class="small text-muted mb-1 font-weight-bold"><i class="far fa-calendar-alt mr-1 text-info"></i> Tanggal Log</label>
<input type="date" name="date" class="form-control form-control-dark w-100" value="<?= htmlspecialchars($filterDate) ?>" title="Tanggal Log" onchange="this.form.submit();">
</div>
<div class="col-xl-2 col-lg-3 col-md-4 col-sm-6 mb-2 mb-xl-0">
<label class="small text-muted mb-1 font-weight-bold"><i class="far fa-clock mr-1 text-info"></i> Rentang Jam</label>
<div class="input-group w-100">
<input type="time" name="time_start" class="form-control form-control-dark px-1" value="<?= htmlspecialchars($filterTimeStart) ?>" title="Waktu Mulai">
<div class="input-group-prepend input-group-append">
<span class="input-group-text bg-dark border-secondary text-muted px-1">-</span>
</div>
<input type="time" name="time_end" class="form-control form-control-dark px-1" value="<?= htmlspecialchars($filterTimeEnd) ?>" title="Waktu Selesai">
</div>
</div>
<div class="col-xl-2 col-lg-3 col-md-4 col-sm-6 mb-2 mb-xl-0">
<label class="small text-muted mb-1 font-weight-bold"><i class="fas fa-layer-group mr-1 text-info"></i> Channel</label>
<select name="channel" class="form-control form-control-dark w-100" onchange="this.form.submit();">
<option value="all" <?= $filterChannel === 'all' ? 'selected' : '' ?>>-- Semua Channel --</option>
<option value="session" <?= $filterChannel === 'session' ? 'selected' : '' ?>>Session Lifecycle</option>
<option value="auth" <?= $filterChannel === 'auth' ? 'selected' : '' ?>>Autentikasi & Login</option>
<option value="access" <?= $filterChannel === 'access' ? 'selected' : '' ?>>Akses Halaman & Modul</option>
<option value="activity" <?= $filterChannel === 'activity' ? 'selected' : '' ?>>Aktivitas Petugas/User</option>
<option value="error" <?= $filterChannel === 'error' ? 'selected' : '' ?>>Error & Exception</option>
<option value="performance" <?= $filterChannel === 'performance' ? 'selected' : '' ?>>Performance & Query</option>
<option value="security" <?= $filterChannel === 'security' ? 'selected' : '' ?>>Security & BruteForce</option>
<option value="system" <?= $filterChannel === 'system' ? 'selected' : '' ?>>System & Retention</option>
</select>
</div>
<div class="col-xl-1 col-lg-2 col-md-3 col-sm-6 mb-2 mb-xl-0">
<label class="small text-muted mb-1 font-weight-bold"><i class="fas fa-exclamation-triangle mr-1 text-info"></i> Level</label>
<select name="level" class="form-control form-control-dark w-100" style="padding-left: 4px; padding-right: 4px;" onchange="this.form.submit();">
<option value="all" <?= $filterLevel === 'all' ? 'selected' : '' ?>>Semua</option>
<option value="DEBUG" <?= $filterLevel === 'DEBUG' ? 'selected' : '' ?>>DEBUG</option>
<option value="INFO" <?= $filterLevel === 'INFO' ? 'selected' : '' ?>>INFO</option>
<option value="WARNING" <?= $filterLevel === 'WARNING' ? 'selected' : '' ?>>WARN</option>
<option value="ERROR" <?= $filterLevel === 'ERROR' ? 'selected' : '' ?>>ERR</option>
<option value="CRITICAL" <?= $filterLevel === 'CRITICAL' ? 'selected' : '' ?>>CRIT</option>
</select>
</div>
<div class="col-xl-2 col-lg-4 col-md-5 col-sm-6 mb-2 mb-xl-0">
<label class="small text-muted mb-1 font-weight-bold"><i class="fas fa-search mr-1 text-info"></i> Kata Kunci</label>
<input type="text" name="search" class="form-control form-control-dark w-100" placeholder="Cari NIP, URI, IP, Pesan..." value="<?= htmlspecialchars($filterSearch) ?>">
</div>
<div class="col-xl-1 col-lg-2 col-md-4 col-sm-6 mb-2 mb-xl-0 d-flex">
<button type="submit" class="btn btn-teal mr-1 px-2 flex-fill" title="Filter & Cari Log"><i class="fas fa-filter mr-1"></i> Filter</button>
<a href="forensic_viewer.php" class="btn btn-outline-secondary px-2" title="Reset Semua Filter"><i class="fas fa-undo"></i></a>
</div>
</form>
</div>
</div>
<!-- MAIN TABBED CONTAINER: LOG ENTRIES & BACKUP ARCHIVES -->
<div class="card card-stat mb-4">
<div class="card-header bg-dark p-0 border-bottom border-secondary">
<ul class="nav nav-tabs nav-tabs-custom border-0" id="forensicTab" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="logs-tab" data-toggle="tab" href="#tab-logs" role="tab" aria-controls="tab-logs" aria-selected="true">
<i class="fas fa-list-alt mr-2 text-info"></i> Log Entries (<?= number_format(count($logs)) ?> baris)
<?php if ($filterCorrelation): ?>
<span class="badge badge-info ml-2">Correlation: <?= htmlspecialchars($filterCorrelation) ?></span>
<?php endif; ?>
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="backup-tab" data-toggle="tab" href="#tab-backup" role="tab" aria-controls="tab-backup" aria-selected="false">
<i class="fas fa-archive mr-2 text-warning"></i> Arsip Log Backup (>30 Hari)
<span class="badge badge-secondary ml-1"><?= count($backups) ?> File</span>
</a>
</li>
</ul>
</div>
<div class="card-body p-0">
<div class="tab-content" id="forensicTabContent">
<!-- TAB 1: LOG ENTRIES -->
<div class="tab-pane fade show active" id="tab-logs" role="tabpanel" aria-labelledby="logs-tab">
<div class="d-flex flex-column flex-md-row align-items-md-center justify-content-between p-3 border-bottom border-secondary" style="background: #0f172a;">
<div class="small text-muted mb-2 mb-md-0">
<i class="fas fa-info-circle mr-1 text-info"></i> Tanggal Log: <strong><?= htmlspecialchars($filterDate) ?></strong>
<span class="mx-2 text-secondary">|</span>
<span id="showingLogRange">
Menampilkan <strong><?= $totalLogs > 0 ? $offset + 1 : 0 ?> - <?= min($offset + $perPage, $totalLogs) ?></strong> dari <strong><?= number_format($totalLogs) ?></strong> baris (Halaman <?= $currentPage ?> dari <?= $totalPages ?>)
</span>
</div>
<div class="d-flex align-items-center flex-wrap">
<!-- Dropdown Pilihan Jumlah Baris per Halaman -->
<form method="GET" action="forensic_viewer.php" class="form-inline mr-3 mb-1 mb-md-0">
<?php foreach ($_GET as $k => $v): ?>
<?php if (!in_array($k, ['limit', 'page', 'ajax'])): ?>
<input type="hidden" name="<?= htmlspecialchars($k) ?>" value="<?= htmlspecialchars($v) ?>">
<?php endif; ?>
<?php endforeach; ?>
<label class="small text-muted mr-2">Limit:</label>
<select name="limit" class="form-control form-control-sm form-control-dark" onchange="this.form.submit()" title="Jumlah log per halaman">
<option value="50" <?= $perPage == 50 ? 'selected' : '' ?>>50 / hlm</option>
<option value="100" <?= $perPage == 100 ? 'selected' : '' ?>>100 / hlm (Default)</option>
<option value="250" <?= $perPage == 250 ? 'selected' : '' ?>>250 / hlm</option>
<option value="500" <?= $perPage == 500 ? 'selected' : '' ?>>500 / hlm</option>
<option value="1000" <?= $perPage == 1000 ? 'selected' : '' ?>>1000 / hlm</option>
</select>
</form>
<a href="forensic_viewer.php?<?= http_build_query(array_merge($_GET, ['export' => 'csv'])) ?>" class="btn btn-sm btn-outline-success mr-2"><i class="fas fa-file-csv mr-1"></i> Export CSV</a>
<a href="forensic_viewer.php?<?= http_build_query(array_merge($_GET, ['export' => 'json'])) ?>" class="btn btn-sm btn-outline-info"><i class="fas fa-file-code mr-1"></i> Export JSON</a>
</div>
</div>
<div class="table-responsive">
<table class="table table-custom mb-0">
<thead>
<tr>
<th style="width: 125px;">Waktu</th>
<th style="width: 80px;">Level</th>
<th style="width: 95px;">Channel</th>
<th style="width: 110px;">IP Address</th>
<th style="width: 110px;">User NIP</th>
<th>Pesan / Event</th>
<th style="width: 65px;">Detail</th>
</tr>
</thead>
<tbody id="logTableBody">
<?php if (empty($logs)): ?>
<tr>
<td colspan="7" class="text-center text-muted py-5">
<i class="fas fa-search fa-2x mb-3 d-block"></i>
Tidak ada log ditemukan untuk kriteria filter ini.
</td>
</tr>
<?php else: ?>
<?php foreach ($pagedLogs as $i => $l): ?>
<?php $renderLogRow($l, $offset + $i); ?>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- PAGINATION & INFINITE SCROLL / LOAD MORE FOOTER -->
<?php if ($totalLogs > 0): ?>
<div class="d-flex flex-column flex-md-row align-items-center justify-content-between p-3 border-top border-secondary" style="background: #0f172a;">
<div class="small text-muted mb-2 mb-md-0">
Menampilkan Halaman <strong><?= $currentPage ?></strong> dari <strong><?= $totalPages ?></strong> (Total <strong><?= number_format($totalLogs) ?></strong> log)
</div>
<?php if ($totalPages > 1): ?>
<nav aria-label="Log Navigation" class="mb-2 mb-md-0">
<ul class="pagination pagination-sm mb-0">
<?php
$pageParams = $_GET;
unset($pageParams['page'], $pageParams['ajax']);
$pageScript = 'forensic_viewer.php';
?>
<!-- First & Prev -->
<li class="page-item <?= $currentPage <= 1 ? 'disabled' : '' ?>">
<a class="page-link bg-dark border-secondary text-info" href="<?= $pageScript ?>?<?= http_build_query(array_merge($pageParams, ['page' => 1])) ?>#tab-logs" title="Halaman Pertama">&laquo; Pertama</a>
</li>
<li class="page-item <?= $currentPage <= 1 ? 'disabled' : '' ?>">
<a class="page-link bg-dark border-secondary text-info" href="<?= $pageScript ?>?<?= http_build_query(array_merge($pageParams, ['page' => max(1, $currentPage - 1)])) ?>#tab-logs" title="Sebelumnya">&lsaquo; Prev</a>
</li>
<!-- Page Numbers -->
<?php
$startP = max(1, $currentPage - 2);
$endP = min($totalPages, $currentPage + 2);
if ($startP > 1) {
echo '<li class="page-item disabled"><span class="page-link bg-dark border-secondary text-muted">...</span></li>';
}
for ($p = $startP; $p <= $endP; $p++):
?>
<li class="page-item <?= $p == $currentPage ? 'active' : '' ?>">
<a class="page-link <?= $p == $currentPage ? 'bg-teal border-teal text-white font-weight-bold' : 'bg-dark border-secondary text-info' ?>" href="<?= $pageScript ?>?<?= http_build_query(array_merge($pageParams, ['page' => $p])) ?>#tab-logs"><?= $p ?></a>
</li>
<?php endfor; ?>
<?php if ($endP < $totalPages): ?>
<li class="page-item disabled"><span class="page-link bg-dark border-secondary text-muted">...</span></li>
<?php endif; ?>
<!-- Next & Last -->
<li class="page-item <?= $currentPage >= $totalPages ? 'disabled' : '' ?>">
<a class="page-link bg-dark border-secondary text-info" href="<?= $pageScript ?>?<?= http_build_query(array_merge($pageParams, ['page' => min($totalPages, $currentPage + 1)])) ?>#tab-logs" title="Berikutnya">Next &rsaquo;</a>
</li>
<li class="page-item <?= $currentPage >= $totalPages ? 'disabled' : '' ?>">
<a class="page-link bg-dark border-secondary text-info" href="<?= $pageScript ?>?<?= http_build_query(array_merge($pageParams, ['page' => $totalPages])) ?>#tab-logs" title="Halaman Terakhir">Terakhir &raquo;</a>
</li>
</ul>
</nav>
<?php endif; ?>
<!-- Infinite Scroll / Load More Button -->
<?php if ($currentPage < $totalPages): ?>
<button id="btnLoadMore" type="button" class="btn btn-sm btn-teal" onclick="loadNextPageAjax()">
<i class="fas fa-spinner fa-spin mr-1 d-none" id="spinnerLoadMore"></i>
<i class="fas fa-plus-circle mr-1" id="iconLoadMore"></i> Muat <?= min($perPage, $totalLogs - ($offset + $perPage)) ?> Log Berikutnya (Lazy Load)
</button>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<!-- TAB 2: ARSIP BACKUP -->
<div class="tab-pane fade" id="tab-backup" role="tabpanel" aria-labelledby="backup-tab">
<div class="d-flex align-items-center justify-content-between p-3 border-bottom border-secondary" style="background: #0f172a;">
<div class="small text-muted">
<i class="fas fa-shield-alt mr-1 text-warning"></i> Berkas log berusia &gt;30 hari diarsipkan secara kompresi per bulan.
</div>
<form method="POST" style="display:inline;">
<input type="hidden" name="action" value="run_retention">
<button type="submit" class="btn btn-sm btn-outline-warning"><i class="fas fa-sync-alt mr-1"></i> Jalankan Retention & Backup Manual</button>
</form>
</div>
<?php if (empty($backups)): ?>
<div class="p-5 text-center text-muted small">
<i class="fas fa-archive fa-2x mb-3 d-block text-secondary"></i>
Belum ada file arsip log zip di folder <code>logs/backup/</code>. File log berusia >30 hari akan diarsipkan otomatis.
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-custom mb-0">
<thead>
<tr>
<th>Nama Berkas Arsip</th>
<th>Ukuran File</th>
<th>Waktu Pembuatan</th>
<th>Aksi & Status</th>
</tr>
</thead>
<tbody>
<?php foreach ($backups as $bk): ?>
<tr>
<td><i class="fas fa-file-archive text-warning mr-2"></i><strong><?= htmlspecialchars($bk['filename']) ?></strong></td>
<td><?= $bk['size_mb'] ?> MB</td>
<td><?= $bk['mtime'] ?></td>
<td>
<a href="forensic_viewer.php?source=<?= urlencode($bk['filename']) ?>#tab-logs" class="btn btn-sm btn-outline-info mr-2" title="Baca seluruh data log dari berkas ZIP ini">
<i class="fas fa-eye mr-1"></i> Baca Log ZIP
</a>
<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i> Safe Archive</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
<script src="library/jquery-3.3.1/jquery-3.3.1.min.js"></script>
<script src="library/bootstrap-4.1.3/js/bootstrap.bundle.min.js"></script>
<script>
/* Helper Salin Text ke Clipboard (Dukungan HTTPS, HTTP & Fallback Textarea) */
function copyTextToClipboard(text, btn) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(function() {
showCopyToast(btn);
}).catch(function() {
fallbackCopyText(text, btn);
});
} else {
fallbackCopyText(text, btn);
}
}
function fallbackCopyText(text, btn) {
var textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.width = "2em";
textArea.style.height = "2em";
textArea.style.padding = "0";
textArea.style.border = "none";
textArea.style.outline = "none";
textArea.style.boxShadow = "none";
textArea.style.background = "transparent";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
var successful = document.execCommand('copy');
if (successful) {
showCopyToast(btn);
} else {
alert('Gagal menyalin JSON.');
}
} catch (err) {
alert('Gagal menyalin JSON: ' + err);
}
document.body.removeChild(textArea);
}
function showCopyToast(btn) {
if (btn) {
var origHTML = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check text-success mr-1"></i> Tersalin!';
setTimeout(function() { btn.innerHTML = origHTML; }, 2000);
} else {
alert('JSON berhasil disalin ke clipboard!');
}
}
/* AJAX Infinite Scroll / Load More Handler */
var currentPage = <?= (int)$currentPage ?>;
var totalPages = <?= (int)$totalPages ?>;
var perPageLimit = <?= (int)$perPage ?>;
function loadNextPageAjax() {
if (currentPage >= totalPages) return;
var btn = document.getElementById('btnLoadMore');
var spinner = document.getElementById('spinnerLoadMore');
var icon = document.getElementById('iconLoadMore');
if (btn) btn.disabled = true;
if (spinner) spinner.classList.remove('d-none');
if (icon) icon.classList.add('d-none');
var nextPage = currentPage + 1;
var params = new URLSearchParams(window.location.search);
params.set('page', nextPage);
params.set('ajax', '1');
fetch('forensic_viewer.php?' + params.toString())
.then(function(res) { return res.json(); })
.then(function(data) {
if (data && data.html) {
var tbody = document.getElementById('logTableBody');
if (tbody) {
tbody.insertAdjacentHTML('beforeend', data.html);
}
currentPage = data.currentPage;
totalPages = data.totalPages;
var showingRange = document.getElementById('showingLogRange');
if (showingRange && data.countLoaded) {
var endRange = Math.min(nextPage * perPageLimit, data.totalLogs);
showingRange.innerHTML = 'Menampilkan <strong>1 - ' + endRange.toLocaleString() + '</strong> dari <strong>' + data.totalLogs.toLocaleString() + '</strong> baris (Halaman ' + currentPage + ' dari ' + totalPages + ')';
}
if (!data.hasNext && btn) {
if (btn.parentNode) btn.parentNode.removeChild(btn);
} else if (btn) {
btn.disabled = false;
var remaining = data.totalLogs - (nextPage * perPageLimit);
var nextChunk = Math.min(perPageLimit, Math.max(0, remaining));
btn.innerHTML = '<i class="fas fa-plus-circle mr-1"></i> Muat ' + nextChunk + ' Log Berikutnya (Lazy Load)';
}
}
})
.catch(function(err) {
console.error('Error loading next page:', err);
alert('Gagal memuat log berikutnya.');
if (btn) btn.disabled = false;
})
.finally(function() {
if (spinner) spinner.classList.add('d-none');
if (icon) icon.classList.remove('d-none');
});
}
(function() {
// Secret Key Shortcut Listener: F8, Ctrl+Alt+F, Ctrl+Shift+F
document.addEventListener('keydown', function(e) {
if (e.key === 'F8' || e.keyCode === 119 ||
(e.ctrlKey && e.altKey && (e.key === 'f' || e.key === 'F' || e.keyCode === 70)) ||
(e.ctrlKey && e.shiftKey && (e.key === 'F' || e.key === 'f' || e.keyCode === 70))) {
e.preventDefault();
window.location.href = 'forensic_viewer.php';
}
});
var secretBuf = "";
var secretTimer = null;
document.addEventListener('keypress', function(e) {
var tag = (e.target.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || e.target.isContentEditable) return;
secretBuf += String.fromCharCode(e.which).toLowerCase();
clearTimeout(secretTimer);
secretTimer = setTimeout(function() { secretBuf = ""; }, 2500);
if (secretBuf.indexOf("forensic") !== -1 || secretBuf.indexOf("inspector") !== -1) {
secretBuf = "";
window.location.href = "forensic_viewer.php";
}
});
})();
</script>
</body>
</html>