1494 lines
76 KiB
Plaintext
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>×</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">« 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">‹ 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 ›</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 »</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 >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>
|