SIMON Master Console Rebuild
True command center for SIMON + WEB360
This rebuild gives you a cleaner production spine: interactive tree, live viewer, guarded editor, dynamic graphics, architecture map, and note storage. It is designed to sit beside your existing
console.php
and become the stronger replacement path.
Dashboard
Tree
Viewer
System Map
File Registry
Notes
Files
15,310
Folders
408
Scanned Size
3.84 GB
PHP Files
891
Editable Text Files
12,571
File viewer
guarded to /htdocs
/sitelogs/guardian_all.php
<?php declare(strict_types=1); require_once __DIR__ . '/_bootstrap.php'; /************************************************* * SIMON Guardian — One-File Admin (Summary/Files/IP/Tail/Security + Sessions) * Drop in /sitelogs (PHP 8+). Works on IONOS shared hosting. * Adds: * - Device classification (UA → desktop/mobile/tablet/bot) * - Sessionization (IP+UA, 30-min gap) → time-on-site estimate * - Per-IP visits, avg session duration, bounce count * - Session CSV export (?tab=summary&download=sessions) *************************************************/ declare(strict_types=1); date_default_timezone_set('America/Chicago'); /* ========= Config ========= */ $CFG = [ // Where to look for logs (works on IONOS with nested htdocs) 'LOG_DIRS' => array_values(array_filter(array_map('realpath', [ __DIR__, // /sitelogs dirname(__DIR__) . '/logs', // /htdocs/logs dirname(dirname(__DIR__)) . '/logs', // /htdocs/htdocs/logs dirname(dirname(dirname(__DIR__))) . '/logs', // /logs (IONOS default) ]))), 'UPLOADS_ENABLED' => true, 'ALLOWED_EXT' => ['log','txt','gz'], 'MAX_UPLOAD_BYTES' => 25 * 1024 * 1024, 'DEFAULT_TAIL_MB' => 8, 'MAX_TAIL_MB' => 128, 'DEFAULT_TOP' => 15, 'CACHE_SECONDS' => 30, 'MAIL_FROM' => 'admin@gaylordsinclair.com', 'BRAND_TITLE' => 'SIMON Traffic Intelligence Console', 'BRAND_FOOTER' => '© '.date('Y').' Gaylord Sinclair — SIMON Guardian', // Optional IP geointel (ipinfo.io). Leave blank to disable. 'IPINFO_TOKEN' => '', // e.g. 'pk_xxxxxxxxxxxxxxxxxxxxx' ]; /* ========= Tiny helpers ========= */ function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES|ENT_SUBSTITUTE, 'UTF-8'); } function is_gz(string $f): bool { return str_ends_with(strtolower($f), '.gz'); } function bad_name(string $f): bool { if (!preg_match('/^[A-Za-z0-9._-]+$/', $f)) return true; if (str_contains($f,'..') || $f === '' || $f[0] === '/') return true; return !preg_match('/\.(log|txt|gz)$/i', $f); } function list_logs_multi(array $dirs): array { $pats = ['access*.log*','*.log','*.gz','*.txt']; $found=[]; foreach ($dirs as $d) { if (!is_dir($d)) continue; foreach ($pats as $p) foreach (glob($d.'/'.$p) ?: [] as $path) if (is_file($path)) $found[]=$path; } $found = array_values(array_unique($found)); usort($found, fn($a,$b)=>(@filemtime($b)?:0)<=>(@filemtime($a)?:0)); return $found; } function list_logs(): array { global $CFG; return list_logs_multi($CFG['LOG_DIRS']); } function newest_log(): ?string { $a=list_logs(); return $a[0]??null; } function read_tail_mb(string $path, int $mb): iterable { if (is_gz($path)) { $gz=@gzopen($path,'rb'); if(!$gz) return; while(!gzeof($gz)){ $line=gzgets($gz); if($line===false) break; yield $line; } gzclose($gz); return; } $fh=@fopen($path,'rb'); if(!$fh) return; $size=@filesize($path)?:0; $chunk=max(1,$mb)*1024*1024; $start=max(0,$size-$chunk); fseek($fh,$start); if($start>0) fgets($fh); while(!feof($fh)){ $line=fgets($fh); if($line===false) break; yield $line; } fclose($fh); } function parse_dt(?string $s): ?DateTime { if(!$s) return null; if(!preg_match('/^(\d{2}\/[A-Za-z]{3}\/\d{4}:\d{2}:\d{2}:\d{2})\s+([+-]\d{4})$/',$s,$m)) return null; $dt=DateTime::createFromFormat('d/M/Y:H:i:s O',$m[1].' '.$m[2]); if(!$dt) return null; $dt->setTimezone(new DateTimeZone(date_default_timezone_get())); return $dt; } class C implements JsonSerializable { public array $c=[]; function add(string $k,int $n=1):void{$this->c[$k]=($this->c[$k]??0)+$n;} function top(int $n): array { arsort($this->c); $o=[]; $i=0; foreach($this->c as $k=>$v){ $o[]=['item'=>$k,'count'=>$v]; if(++$i>=$n)break; } return $o; } function jsonSerialize(): mixed { return $this->c; } } /* ========= Patterns & Intel ========= */ $patCombined='/(?P<ip>[0-9a-fA-F:\.]+)\s+\S+\s+\S+\s+\[(?P<dt>[^\]]+)\]\s+"(?P<method>[A-Z]+)\s+(?P<path>[^"]*?)\s+[^"]*"\s+(?P<status>\d{3})\s+(?P<size>-|\d+)\s+"(?P<ref>[^"]*)"\s+"(?P<ua>[^"]*)"/'; $patSimple ='/(?P<ip>[0-9a-fA-F:\.]+)\s+-\s+-\s+\[(?P<dt>[^\]]+)\]\s+"(?P<method>[A-Z]+)\s+(?P<path>[^ ]+)\s+[^"]*"\s+(?P<status>\d{3})\s+(?P<size>-|\d+)/'; $patLoose ='/(?P<ip>[0-9a-fA-F:\.]+)\s+\S+\s+\S+\s+\[(?P<dt>[^\]]+)\]\s+"(?P<method>[A-Z]+)\s+(?P<path>[^"]*?)\s+[^"]*"\s+(?P<status>\d{3})\s+(?P<size>-|\d+)(?:\s+"(?P<ref>[^"]*)")?(?:\s+"(?P<ua>[^"]*)")?/'; function is_public_ip(string $ip): bool { if (!filter_var($ip, FILTER_VALIDATE_IP)) return false; return (bool)filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); } function rdns_lookup(string $ip): string { $h = @gethostbyaddr($ip); return ($h && $h!==$ip) ? $h : ''; } function ipinfo_fetch(string $ip, string $token): array { if ($token==='' || !is_public_ip($ip)) return []; $ctx = stream_context_create(['http'=>['timeout'=>3,'ignore_errors'=>true]]); $u = 'https://ipinfo.io/'.rawurlencode($ip).'/json?token='.rawurlencode($token); $raw = @file_get_contents($u, false, $ctx); $j = json_decode($raw ?: '[]', true); return is_array($j) ? $j : []; } function ipinfo_cached(string $ip, string $token): array { if ($token==='' || !is_public_ip($ip)) return []; $d=__DIR__.'/cache_ipinfo'; if(!is_dir($d)) @mkdir($d,0755,true); $k=$d.'/'.preg_replace('/[^A-Za-z0-9:_-]/','_',$ip).'.json'; if (is_file($k) && time()-filemtime($k) < 86400) { $j=json_decode((string)@file_get_contents($k),true); if(is_array($j)) return $j; } $j=ipinfo_fetch($ip,$token); if($j) @file_put_contents($k,json_encode($j,JSON_UNESCAPED_SLASHES)); return $j ?: []; } /* ========= NEW: Device classification ========= */ function device_class_from_ua(string $ua): string { $ua = strtolower($ua); if (preg_match('/bot|spider|crawler|bingpreview|facebookexternalhit|slackbot|discordbot|monitor|curl|wget|httpclient|ahrefs|semrush/i', $ua)) return 'bot'; if (preg_match('/ipad|tablet|playbook|silk|kindle|nexus 7|nexus 10|galaxy tab|xoom|transformer/i', $ua)) return 'tablet'; if (preg_match('/mobile|iphone|ipod|android|blackberry|bb10|opera mini|windows phone|phone/i', $ua)) return 'mobile'; return 'desktop'; } /* ========= Summary (unchanged core) ========= */ function summarize(array $paths, int $tailMb, int $topN): array { global $patCombined,$patSimple,$patLoose,$CFG; $byIP=new C; $byStatus=new C; $byMethod=new C; $byPath=new C; $byHour=new C; $byDay=new C; $refTop=new C; $uaTop=new C; $byDayBytes=new C; $botsDaily=new C; $humDaily=new C; $total=0; $parsed=0; foreach ($paths as $p) { foreach (read_tail_mb($p,$tailMb) as $line) { $total++; if (!preg_match($patCombined,$line,$m) && !preg_match($patSimple,$line,$m) && !preg_match($patLoose,$line,$m)) continue; $ip=trim($m['ip']??''); $dt=parse_dt($m['dt']??''); $method=trim($m['method']??''); $path=trim(($m['path']??'-')?:'-'); $status=(int)($m['status']??0); $bytes = ($m['size']??'-')==='-' ? 0 : (int)$m['size']; $ref=trim(($m['ref']??'-')?:'-'); $ua=trim(($m['ua']??'-')?:'-'); $parsed++; $byIP->add($ip); $byStatus->add((string)$status); $byMethod->add($method); $byPath->add($path); if($dt){ $d=$dt->format('Y-m-d'); $byDay->add($d); $byHour->add($dt->format('Y-m-d H:00')); $byDayBytes->add($d,$bytes); $isBot=(bool)preg_match('/bot|spider|crawler|preview|monitor|curl|wget|httpclient|facebookexternalhit|WhatsApp|Slackbot|Discordbot|Ahrefs|Semrush|BingPreview/i',$ua); if($isBot) $botsDaily->add($d); else $humDaily->add($d); } if($ref!=='-') $refTop->add($ref); if($ua!=='-') $uaTop->add($ua); } } // Enrich Top IPs with Geo + RDNS $topIpEnriched=[]; $topIpList=$byIP->top($topN); foreach($topIpList as $entry){ $ip=$entry['item']; $cnt=(int)$entry['count']; $info=[]; $rdns=''; if(!empty($CFG['IPINFO_TOKEN'])) $info=ipinfo_cached($ip,$CFG['IPINFO_TOKEN']); $rdns=rdns_lookup($ip); $city=$info['city']??''; $region=$info['region']??''; $country=$info['country']??''; $org=$info['org']??''; $loc=implode(', ',array_filter([$city,$region,$country])); $label=$ip.($loc?' · '.$loc:'').($org?' · '.$org:'').($rdns?' · '.$rdns:''); $topIpEnriched[]=['item'=>$ip,'count'=>$cnt,'city'=>$city,'region'=>$region,'country'=>$country,'org'=>$org,'rdns'=>$rdns,'label'=>$label]; } // Where they went (top paths per top IP) $ip_top_paths=[]; foreach($topIpEnriched as $E){ $ip=$E['item']; $bucket=new C(); foreach($paths as $p) foreach(read_tail_mb($p,$tailMb) as $line){ if (!preg_match($patCombined,$line,$m) && !preg_match($patSimple,$line,$m) && !preg_match($patLoose,$line,$m)) continue; if (trim($m['ip']??'')!==$ip) continue; $bucket->add(trim(($m['path']??'-')?:'-')); } $ip_top_paths[$ip]=$bucket->top(6); } $daily=$byDay->c; ksort($daily); return [ 'files'=>array_map('basename',$paths), 'tail_mb'=>$tailMb, 'total_lines'=>$total,'parsed_lines'=>$parsed,'total_hits'=>$parsed, 'unique_ips'=>count($byIP->c), 'status_breakdown'=>$byStatus->c, 'method_breakdown'=>$byMethod->c, 'top_paths'=>$byPath->top($topN), 'top_ips'=>$topIpEnriched, 'ip_top_paths'=>$ip_top_paths, 'top_referrers'=>$refTop->top($topN), 'top_user_agents'=>$uaTop->top($topN), 'hourly_hits'=>$byHour->c, 'daily_hits'=>$daily, 'bytes_daily'=>$byDayBytes->c, 'bots_daily'=>$botsDaily->c, 'humans_daily'=>$humDaily->c ]; } /* ========= NEW: Sessionization (IP+UA, 30min gap) ========= */ function sessionize_by_ip(array $paths, int $tailMb, int $gap_seconds = 1800): array { global $patCombined,$patSimple,$patLoose; $eventsByKey = []; // key = ip|ua (helps split NAT traffic a bit) foreach ($paths as $p) { foreach (read_tail_mb($p, $tailMb) as $line) { if (!preg_match($patCombined,$line,$m) && !preg_match($patSimple,$line,$m) && !preg_match($patLoose,$line,$m)) continue; $ip = trim($m['ip'] ?? ''); $ua = trim(($m['ua'] ?? '-') ?: '-'); $dt = parse_dt($m['dt'] ?? null); $path = trim(($m['path'] ?? '-') ?: '-'); if (!$ip || !$dt) continue; $ts = strtotime($dt->format('Y-m-d H:i:s')); $key = $ip.'|'.$ua; $eventsByKey[$key][] = ['ip'=>$ip,'ua'=>$ua,'ts'=>$ts,'dt'=>$dt->format('Y-m-d H:i:s'),'path'=>$path]; } } $sessions=[]; $by_ip=[]; foreach ($eventsByKey as $key=>$events) { usort($events, fn($a,$b)=>$a['ts'] <=> $b['ts']); $cur = null; foreach ($events as $e) { if ($cur === null) { $cur = ['ip'=>$e['ip'],'ua'=>$e['ua'],'device'=>device_class_from_ua($e['ua']),'start_ts'=>$e['ts'],'end_ts'=>$e['ts'],'hits'=>1,'paths'=>[$e['path']]]; continue; } if ($e['ts'] - $cur['end_ts'] > $gap_seconds) { // flush $cur['start'] = date('Y-m-d H:i:s',$cur['start_ts']); $cur['end'] = date('Y-m-d H:i:s',$cur['end_ts']); $cur['duration'] = max(0,$cur['end_ts']-$cur['start_ts']); $sessions[] = $cur; $cur = ['ip'=>$e['ip'],'ua'=>$e['ua'],'device'=>device_class_from_ua($e['ua']),'start_ts'=>$e['ts'],'end_ts'=>$e['ts'],'hits'=>1,'paths'=>[$e['path']]]; } else { $cur['end_ts'] = $e['ts']; $cur['hits']++; $cur['paths'][]=$e['path']; } } if ($cur !== null) { $cur['start'] = date('Y-m-d H:i:s',$cur['start_ts']); $cur['end'] = date('Y-m-d H:i:s',$cur['end_ts']); $cur['duration'] = max(0,$cur['end_ts']-$cur['start_ts']); $sessions[] = $cur; } } // Per-IP aggregates foreach ($sessions as $s) { $ip=$s['ip']; if(!isset($by_ip[$ip])) $by_ip[$ip]=['visits'=>0,'sum_secs'=>0,'bounces'=>0]; $by_ip[$ip]['visits']++; $by_ip[$ip]['sum_secs'] += $s['duration']; if ($s['hits']<=1) $by_ip[$ip]['bounces']++; } foreach ($by_ip as $ip=>$r) { $by_ip[$ip]['avg_session_secs'] = $r['visits'] ? round($r['sum_secs']/$r['visits'],2) : 0; } // Summary $total_sessions = count($sessions); $sum_secs = array_sum(array_map(fn($s)=>$s['duration'], $sessions)); $avg_session = $total_sessions ? round($sum_secs / $total_sessions, 2) : 0; $bounce_count = array_sum(array_map(fn($s)=>$s['hits']<=1?1:0, $sessions)); $bounce_rate = $total_sessions ? round($bounce_count / $total_sessions, 3) : 0; $durs = array_map(fn($s)=>$s['duration'],$sessions); sort($durs); $median = 0; $m=count($durs); if ($m) $median = ($m%2) ? $durs[intdiv($m,2)] : (($durs[$m/2-1]+$durs[$m/2])/2); return [ 'sessions'=>$sessions, 'by_ip'=>$by_ip, 'summary'=>[ 'total_sessions'=>$total_sessions, 'avg_session_secs'=>$avg_session, 'median_session_secs'=>$median, 'bounce_rate'=>$bounce_rate ] ]; } /* ========= Routing ========= */ $tab = strtolower($_GET['tab'] ?? 'summary'); // summary | files | ip | tail | security $topN = max(5, (int)($_GET['top'] ?? $CFG['DEFAULT_TOP'])); $tailMb = min($CFG['MAX_TAIL_MB'], max(1,(int)($_GET['tail_mb'] ?? $CFG['DEFAULT_TAIL_MB']))); $allPaths = list_logs(); $names = array_map('basename',$allPaths); $map=array_combine($names,$allPaths)?:[]; $fileArg = trim((string)($_GET['file'] ?? 'auto')); $pick=[]; if($fileArg==='auto'){ $n=newest_log(); if($n) $pick=[$n]; } elseif($fileArg==='all'){ $pick=$allPaths; } else { foreach (array_map('trim', explode(',', $fileArg)) as $n) if($n!=='' && isset($map[$n])) $pick[]=$map[$n]; $pick=array_values(array_unique($pick)); } /* ========= HTML Shell ========= */ function fmt_secs(int $s): string { $h=intdiv($s,3600); $m=intdiv($s%3600,60); $ss=$s%60; return ($h>0?sprintf('%d:%02d:%02d',$h,$m,$ss):sprintf('%d:%02d',$m,$ss)); } function shell_head(string $active): void { global $CFG; ?> <!doctype html><html><head><meta charset="utf-8"> <title><?=h($CFG['BRAND_TITLE'])?></title> <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> <meta name="theme-color" content="#070b16"> <style> :root{--bg:#070b16;--edge:#25345e;--ink:#eaf2ff;--muted:#9db3e6;--c:#6ae0ff;--vio:#7c4dff;--amb:#ffb86b} *{box-sizing:border-box} body{margin:0;background:radial-gradient(circle at 20% 10%,#0d1430,transparent 40%),radial-gradient(circle at 80% 0%,#0b1b33,transparent 45%),linear-gradient(#070b16,#050913);color:var(--ink);font-family:ui-sans-serif,system-ui,Inter,Segoe UI,Roboto} .wrap{max-width:1200px;margin:0 auto;padding:16px} header{position:sticky;top:0;z-index:10;background:#0b1023cc;border-bottom:1px solid var(--edge);backdrop-filter:blur(8px)} .brand{display:flex;align-items:center;gap:10px;padding:12px 16px}.brand h1{margin:0;font-size:18px} nav.tabs{display:flex;gap:10px;border-bottom:1px solid var(--edge);padding:8px 16px} nav.tabs a{padding:8px 12px;border:1px solid var(--edge);border-bottom:none;border-radius:10px 10px 0 0;color:var(--ink);text-decoration:none;opacity:.9;background:rgba(255,255,255,.06)} nav.tabs a.active{background:#18213a;opacity:1} .card{background:linear-gradient(180deg,rgba(255,255,255,.1),rgba(255,255,255,.03));border:1px solid var(--edge);border-radius:16px;padding:12px;margin:12px 0} table{border-collapse:collapse;width:100%}th,td{border:1px solid #1a2444;padding:6px 8px;text-align:left}th{background:#18213a} .mut{color:var(--muted)} .btn{padding:6px 10px;border:1px solid #2a3245;border-radius:10px;background:linear-gradient(180deg,rgba(255,255,255,.12),rgba(255,255,255,.04));color:var(--ink);text-decoration:none} .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px} .kv{display:flex;flex-wrap:wrap;gap:10px;align-items:center} .small{font-size:.9rem;color:#b9c6ea} pre.code{white-space:pre-wrap;word-break:break-word;background:#0e1430;border:1px solid #26345e;border-radius:12px;padding:10px} .badge{display:inline-block;padding:.1rem .5rem;border-radius:.6rem;border:1px solid #334472;background:#122047} </style></head><body> <header><div class="brand"><h1>⚡ <?=h($CFG['BRAND_TITLE'])?></h1><span class="mut">One-File Admin</span></div> <nav class="tabs"> <a class="<?= $active==='summary'?'active':'' ?>" href="?tab=summary">Summary</a> <a class="<?= $active==='files'?'active':'' ?>" href="?tab=files">Files</a> <a class="<?= $active==='ip'?'active':'' ?>" href="?tab=ip">IP Intel</a> <a class="<?= $active==='tail'?'active':'' ?>" href="?tab=tail">Traffic (Live)</a> <a class="<?= $active==='security'?'active':'' ?>" href="?tab=security">Security</a> </nav></header> <div class="wrap"> <?php } function shell_foot(): void { global $CFG; echo "<footer class='card mut' style='text-align:center'>".h($CFG['BRAND_FOOTER'])."</footer></div></body></html>"; } /* ========= FILES TAB ========= */ if ($tab==='files') { shell_head('files'); $dir = __DIR__; echo "<div class='card'><h3>Files in ".h($dir)." & /logs</h3>"; echo "<p class='mut'>Upload raw access logs (.log / .gz). Summary auto-picks newest unless you choose one.</p>"; echo "<table><tr><th>Name</th><th>Size</th><th>Modified</th><th>Type</th><th>Actions</th></tr>"; foreach (list_logs() as $p) { $name=basename($p); $bytes=@filesize($p)?:0; $mtime=@date('Y-m-d H:i:s',@filemtime($p)?:time()); $type=is_gz($p)?'gz':'plain'; echo "<tr><td>".h($name)."</td><td>".number_format((float)$bytes)."</td><td>".h($mtime)."</td><td>".h($type)."</td> <td><a class='btn' href='?tab=summary&file=".urlencode($name)."'>summary</a></td></tr>"; } echo "</table>"; if ($CFG['UPLOADS_ENABLED']) { echo "<form class='kv' method='post' enctype='multipart/form-data'> <label>Upload new log: <input type='file' name='logfile' accept='.log,.gz,.txt'></label> <button class='btn' type='submit' name='__upload' value='1'>Upload</button> <span class='small'>Allowed: ".h(implode(', ',$CFG['ALLOWED_EXT']))." · Max ".number_format($CFG['MAX_UPLOAD_BYTES']/1048576,1)." MB</span> </form>"; if (($_POST['__upload'] ?? '') === '1') { $f=$_FILES['logfile']??null; $msg=''; if(!$f || $f['error']!==UPLOAD_ERR_OK) $msg='Upload failed.'; elseif ($f['size']>$CFG['MAX_UPLOAD_BYTES']) $msg='File too large.'; else { $base=basename($f['name']); if(bad_name($base)) $msg='Bad filename.'; else { $target=$dir.'/'.$base; if(@move_uploaded_file($f['tmp_name'],$target)){ @chmod($target,0644); $msg='Upload ok: '.$base; } else $msg='Save failed.'; }} echo "<p class='mut'>".h($msg)."</p>"; } } echo "</div>"; shell_foot(); exit; } /* ========= SUMMARY TAB ========= */ if ($tab==='summary') { shell_head('summary'); if (!$pick) { $n=newest_log(); if($n) $pick=[$n]; } if (!$pick) { echo "<div class='card'>No logs found here. Go to <b>Files</b> tab to upload one.</div>"; shell_foot(); exit; } // Controls echo "<div class='card'><h3>Controls</h3><form class='kv'> <input type='hidden' name='tab' value='summary'> <label>file: <select name='file'> <option value='auto' ".($fileArg==='auto'?'selected':'').">auto (newest)</option> <option value='all' ".($fileArg==='all'?'selected':'').">all files</option>"; foreach($names as $n) echo "<option value='".h($n)."' ".($fileArg===$n?'selected':'').">".h($n)."</option>"; echo " </select></label> <label>top <input name='top' value='".h((string)$topN)."' size='3'></label> <label>tail_mb <input name='tail_mb' value='".h((string)$tailMb)."' size='4'></label> <button class='btn'>Apply</button> <a class='btn' href='?tab=summary&file=".urlencode($fileArg)."&tail_mb=".$tailMb."&download=sessions'>⬇ Session CSV</a> </form><p class='small mut'>Reading from <b>".h($pick[0])."</b>. For broader comparisons choose <span class='badge'>file=all</span>.</p></div>"; // Core summary $s = summarize($pick,$tailMb,$topN); echo "<div class='grid'> <div class='card'><h3>Totals</h3><table> <tr><th>Total Hits</th><td>".h((string)$s['total_hits'])."</td></tr> <tr><th>Unique IPs</th><td>".h((string)$s['unique_ips'])."</td></tr> <tr><th>Devices</th><td class='mut'>approx: Humans ".h((string)array_sum($s['humans_daily']))." · Bots ".h((string)array_sum($s['bots_daily']))."</td></tr> <tr><th>Log Source</th><td class='mut'>".h($s['files'][0]??'')."</td></tr> </table></div> <div class='card'><h3>Status Codes</h3><table>"; foreach($s['status_breakdown'] as $k=>$v) echo "<tr><td>".h($k)."</td><td>".h((string)$v)."</td></tr>"; echo "</table></div> </div>"; // NEW: Session Estimates $sess = sessionize_by_ip($pick,$tailMb,1800); echo "<div class='card'><h3>Session Estimates</h3><table> <tr><th>Total Sessions</th><td>".h((string)$sess['summary']['total_sessions'])."</td></tr> <tr><th>Avg Session</th><td>".h(fmt_secs((int)round($sess['summary']['avg_session_secs'])))."</td></tr> <tr><th>Median Session</th><td>".h(fmt_secs((int)round($sess['summary']['median_session_secs'])))."</td></tr> <tr><th>Bounce Rate</th><td>".h((string)round($sess['summary']['bounce_rate']*100,1))."%</td></tr> </table> <p class='small mut'>Estimated from IP+UA with a 30-minute inactivity timeout. Single-hit sessions count as bounces.</p> </div>"; // Top Sessions by duration (first 12) usort($sess['sessions'], fn($a,$b)=>$b['duration'] <=> $a['duration']); $topSess = array_slice($sess['sessions'], 0, 12); echo "<div class='card'><h3>Top Sessions (by duration)</h3><div style='max-height:420px;overflow:auto'><table> <tr><th>IP</th><th>Device</th><th>Start → End</th><th>Duration</th><th>Hits</th><th>Paths (sample)</th></tr>"; foreach ($topSess as $row) { $sample = implode(' → ', array_slice(array_unique($row['paths']), 0, 6)); echo "<tr> <td>".h($row['ip'])." <a class='btn' href='?tab=ip&qip=".urlencode($row['ip'])."'>intel</a></td> <td>".h($row['device'])."</td> <td class='small'>".h($row['start'])." → ".h($row['end'])."</td> <td>".h(fmt_secs((int)$row['duration']))."</td> <td>".h((string)$row['hits'])."</td> <td class='small'>".h($sample)."</td> </tr>"; } echo "</table></div></div>"; // Top Pages echo "<div class='card'><h3>Top Pages (paths)</h3><table><tr><th>Path</th><th>Hits</th></tr>"; foreach($s['top_paths'] as $r) echo "<tr><td>".h($r['item'])."</td><td>".h((string)$r['count'])."</td></tr>"; echo "</table></div>"; // Top IPs (+ where they went) echo "<div class='card'><h3>Top IPs (Geo/Org/RDNS) + Where they went</h3> <table><tr><th>IP (enriched)</th><th>Hits</th></tr>"; foreach ($s['top_ips'] as $r) { $ip=$r['item']; $cnt=$r['count']; $label=$r['label'] ?: $ip; echo "<tr><td>".h($label)." <a class='btn' href='?tab=ip&qip=".urlencode($ip)."'>intel</a></td><td>".h((string)$cnt)."</td></tr>"; $paths=$s['ip_top_paths'][$ip]??[]; if($paths){ $list = implode(' · ', array_map(fn($p)=>h($p['item']).' ('.h((string)$p['count']).')', array_slice($paths,0,6))); echo "<tr><td colspan='2' class='small mut'>Where they went: {$list}</td></tr>"; } } echo "</table></div>"; // Referrers & UAs echo "<div class='grid'> <div class='card'><h3>Top Referrers</h3><table><tr><th>Referrer</th><th>Hits</th></tr>"; foreach($s['top_referrers'] as $r) echo "<tr><td>".h($r['item'])."</td><td>".h((string)$r['count'])."</td></tr>"; echo "</table></div> <div class='card'><h3>Top User Agents</h3><table><tr><th>User-Agent</th><th>Hits</th></tr>"; foreach($s['top_user_agents'] as $r) echo "<tr><td>".h($r['item'])."</td><td>".h((string)$r['count'])."</td></tr>"; echo "</table></div></div>"; // CSV download if (($_GET['download'] ?? '') === 'sessions') { header('Content-Type: text/csv; charset=UTF-8'); header('Content-Disposition: attachment; filename="sessions.csv"'); $out = fopen('php://output', 'w'); fputcsv($out, ['ip','device','start','end','duration_secs','hits','paths']); foreach ($sess['sessions'] as $row) { fputcsv($out, [ $row['ip'], $row['device'], $row['start'] ?? date('Y-m-d H:i:s',$row['start_ts']), $row['end'] ?? date('Y-m-d H:i:s',$row['end_ts']), $row['duration'], $row['hits'], implode(' | ', array_unique($row['paths'])) ]); } fclose($out); exit; } shell_foot(); exit; } /* ========= IP TAB ========= */ if ($tab==='ip') { shell_head('ip'); $qip = trim((string)($_GET['qip'] ?? '')); echo "<div class='card'><h3>IP Intelligence</h3> <form class='kv'><input type='hidden' name='tab' value='ip'> <label>IP <input name='qip' value='".h($qip)."' size='24' placeholder='e.g. 66.249.66.1'></label> <label>tail_mb <input name='tail_mb' value='".h((string)$tailMb)."' size='4'></label> <button class='btn'>Analyze</button></form> <p class='small mut'>Paste an IP from the Summary table to see its behavior. Times shown in America/Chicago.</p></div>"; if ($qip===''){ shell_foot(); exit; } if (!filter_var($qip, FILTER_VALIDATE_IP)) { echo "<div class='card'>That doesn't look like a valid IP.</div>"; shell_foot(); exit; } if (!$pick) { $n=newest_log(); if($n) $pick=[$n]; if(!$pick){ echo "<div class='card'>No logs found.</div>"; shell_foot(); exit; } } // Build per-IP profile (requests) $p = (function(array $paths,int $tailMb,string $ip){ global $patCombined,$patSimple,$patLoose,$CFG; $rows=[]; $byDay=new C; $hod=array_fill(0,24,0); $byStatus=new C; $byMethod=new C; $byPath=new C; $byUA=new C; $byRef=new C; $hits=0; $err=0; $first=''; $last=''; foreach($paths as $path) foreach(read_tail_mb($path,$tailMb) as $line){ if (!preg_match($patCombined,$line,$m) && !preg_match($patSimple,$line,$m) && !preg_match($patLoose,$line,$m)) continue; if (trim($m['ip']??'')!==$ip) continue; $dt=parse_dt($m['dt']??''); $method=trim($m['method']??''); $pth=trim(($m['path']??'-')?:'-'); $status=(int)($m['status']??0); $ref=trim(($m['ref']??'-')?:'-'); $ua=trim(($m['ua']??'-')?:'-'); $hits++; if($status>=400) $err++; if($dt){$d=$dt->format('Y-m-d'); $byDay->add($d); $hod[(int)$dt->format('G')]++; $ts=$dt->format('Y-m-d H:i:s'); if($first===''||$ts<$first)$first=$ts; if($last===''||$ts>$last)$last=$ts;} $byStatus->add((string)$status); $byMethod->add($method); $byPath->add($pth); if($ua!=='-')$byUA->add($ua); if($ref!=='-')$byRef->add($ref); if(count($rows)<300) $rows[]=['datetime'=>$dt?$dt->format('Y-m-d H:i:s'):'','method'=>$method,'path'=>$pth,'status'=>(string)$status,'ref'=>$ref,'ua'=>$ua]; } $info = !empty($CFG['IPINFO_TOKEN']) ? ipinfo_cached($ip,$CFG['IPINFO_TOKEN']) : []; $rdns = rdns_lookup($ip); return [ 'ip'=>$ip,'hits'=>$hits,'errors'=>$err,'error_rate'=>$hits?round($err/$hits,3):0, 'first_seen'=>$first,'last_seen'=>$last,'daily'=>$byDay->c,'hour_of_day'=>$hod, 'status_breakdown'=>$byStatus->c,'method_breakdown'=>$byMethod->c, 'top_paths'=>$byPath->top(20),'top_uas'=>$byUA->top(20),'top_refs'=>$byRef->top(20), 'rows'=>$rows,'geo'=>['city'=>$info['city']??'','region'=>$info['region']??'','country'=>$info['country']??'','org'=>$info['org']??'','rdns'=>$rdns] ]; })($pick,$tailMb,$qip); // NEW: Per-IP sessions summary $allSess = sessionize_by_ip($pick,$tailMb,1800); $ipSess = ['visits'=>0,'avg_session_secs'=>0,'bounces'=>0]; if (!empty($allSess['by_ip'][$qip])) $ipSess = $allSess['by_ip'][$qip]; echo "<div class='grid'> <div class='card'><h3>Snapshot</h3><table> <tr><th>IP</th><td>".h($p['ip'])."</td></tr> <tr><th>Public/Private</th><td>".(is_public_ip($qip)?'Public':'Private/Reserved')."</td></tr> <tr><th>Reverse DNS</th><td>".h($p['geo']['rdns']??'')."</td></tr> <tr><th>First seen</th><td>".h($p['first_seen'])."</td></tr> <tr><th>Last seen</th><td>".h($p['last_seen'])."</td></tr> <tr><th>Total hits</th><td>".h((string)$p['hits'])."</td></tr> <tr><th>Errors</th><td>".h((string)$p['errors'])." (rate ".h((string)$p['error_rate']).")</td></tr> <tr><th>Geo/Org</th><td>".h(trim(($p['geo']['city']??'').' '.($p['geo']['region']??'')))." ".h($p['geo']['country']??'')." · ".h($p['geo']['org']??'')."</td></tr> <tr><th>Visits (sessions)</th><td>".h((string)$ipSess['visits'])."</td></tr> <tr><th>Avg session</th><td>".h(fmt_secs((int)round($ipSess['avg_session_secs'])))."</td></tr> <tr><th>Bounces</th><td>".h((string)$ipSess['bounces'])."</td></tr> </table></div> <div class='card'><h3>Status Mix</h3><table>"; foreach($p['status_breakdown'] as $k=>$v) echo "<tr><td>".h($k)."</td><td>".h((string)$v)."</td></tr>"; echo "</table><h3>Method Mix</h3><table>"; foreach($p['method_breakdown'] as $k=>$v) echo "<tr><td>".h($k)."</td><td>".h((string)$v)."</td></tr>"; echo "</table></div></div>"; echo "<div class='grid'> <div class='card'><h3>Top Paths</h3><table><tr><th>Path</th><th>Count</th></tr>"; foreach($p['top_paths'] as $r) echo "<tr><td>".h($r['item'])."</td><td>".h((string)$r['count'])."</td></tr>"; echo "</table></div> <div class='card'><h3>Top Referrers</h3><table><tr><th>Referrer</th><th>Count</th></tr>"; foreach($p['top_refs'] as $r) echo "<tr><td>".h($r['item'])."</td><td>".h((string)$r['count'])."</td></tr>"; echo "</table></div></div>"; echo "<div class='card'><h3>Recent Requests (max 300)</h3><div style='max-height:420px;overflow:auto'> <table><tr><th>datetime</th><th>status</th><th>method</th><th>path</th><th>ref</th><th>ua</th></tr>"; foreach($p['rows'] as $r) echo "<tr><td>".h($r['datetime'])."</td><td>".h($r['status'])."</td><td>".h($r['method'])."</td><td>".h($r['path'])."</td><td>".h($r['ref'])."</td><td>".h($r['ua'])."</td></tr>"; echo "</table></div></div>"; shell_foot(); exit; } /* ========= LIVE TAIL TAB ========= */ if ($tab==='tail') { shell_head('tail'); if (!$pick) { $n=newest_log(); if($n) $pick=[$n]; } if (!$pick) { echo "<div class='card'>No logs found.</div>"; shell_foot(); exit; } $filter = trim((string)($_GET['q'] ?? '')); $lines = max(50, (int)($_GET['lines'] ?? 200)); echo "<div class='card'><h3>Live Tail — Access</h3> <form class='kv'><input type='hidden' name='tab' value='tail'> <label>q <input name='q' value='".h($filter)."' size='24' placeholder='e.g. 404 or /home or 66.249'></label> <label>lines <input name='lines' value='".h((string)$lines)."' size='5'></label> <label>tail_mb <input name='tail_mb' value='".h((string)$tailMb)."' size='4'></label> <button class='btn'>Apply</button></form>"; echo "<div style='max-height:520px;overflow:auto'><pre class='code'>"; $printed=0; foreach($pick as $p) { $buf=[]; foreach(read_tail_mb($p,$tailMb) as $line) { if($filter!=='' && stripos($line,$filter)===false) continue; $buf[] = rtrim($line); if (count($buf) > $lines) array_shift($buf); } echo h("### ".basename($p)." ###\n".implode("\n",$buf)."\n\n"); $printed += count($buf); } if ($printed===0) echo h("No matches. Try clearing the filter."); echo "</pre></div></div>"; shell_foot(); exit; } /* ========= SECURITY TAB ========= */ if ($tab==='security') { shell_head('security'); $headers = [ 'Strict-Transport-Security' => $_SERVER['HTTP_STRICT_TRANSPORT_SECURITY'] ?? '(none)', 'Content-Security-Policy' => $_SERVER['HTTP_CONTENT_SECURITY_POLICY'] ?? '(none)', 'Referrer-Policy' => $_SERVER['HTTP_REFERRER_POLICY'] ?? 'strict-origin-when-cross-origin', 'X-Frame-Options' => $_SERVER['HTTP_X_FRAME_OPTIONS'] ?? 'SAMEORIGIN', 'X-Content-Type-Options' => $_SERVER['HTTP_X_CONTENT_TYPE_OPTIONS'] ?? 'nosniff', 'Permissions-Policy' => $_SERVER['HTTP_PERMISSIONS_POLICY'] ?? 'geolocation=(), microphone=(), camera=()', ]; echo "<div class='grid'> <div class='card'><h3>HTTP Security Headers</h3><table><tr><th>Header</th><th>Value</th></tr>"; foreach($headers as $k=>$v) echo "<tr><td>".h($k)."</td><td>".h($v)."</td></tr>"; echo "</table><p class='small mut'>Add/adjust these in your <code>.htaccess</code>.</p></div> <div class='card'><h3>.htaccess Hardening Generator</h3> <h4>deny-raw-logs</h4><pre class='code'><FilesMatch "^(access|error)\.log(\.|$)"> Require all denied </FilesMatch></pre> <h4>mime-guards</h4><pre class='code'><FilesMatch "\.(phps?|phtml)$"> Require all denied </FilesMatch></pre> <h4>security-headers</h4><pre class='code'><IfModule mod_headers.c> Header set Referrer-Policy "strict-origin-when-cross-origin" Header set Permissions-Policy "geolocation=(), microphone=(), camera=()" Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" env=HTTPS </IfModule></pre> </div> </div>"; shell_foot(); exit; } /* ========= Fallback ========= */ shell_head($tab); echo "<div class='card'>Unknown tab.</div>"; shell_foot();
Save file
Quick jump
open a path
Open