/* Safe-area aware top offset with a fallback for Android */ :root { --statusbar-fallback: 28px; /* tweak 24–32px if still clipped */ --safe-top: env(safe-area-inset-top, var(--statusbar-fallback)); } /* Push fixed elements below the status bar */ .navbar { top: var(--safe-top); } .submenu { top: var(--safe-top); height: calc(100% - var(--safe-top)); } .toggle-menu-btn { top: calc(20px + var(--safe-top)); } /* Your content starts after the navbar (56px) + safe area */ .main-container { margin-top: calc(56px + var(--safe-top)); } ///////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////// /* -------------------- API: POST ingest (JSON or form) -------------------- */ if ($_SERVER['REQUEST_METHOD'] === 'POST') { // 1) Read headers & raw body $headers = function_exists('getallheaders') ? getallheaders() : []; $ct = strtolower($headers['Content-Type'] ?? $_SERVER['CONTENT_TYPE'] ?? ''); $raw = file_get_contents('php://input') ?: ''; // (Optional but VERY useful) log every ingress for debugging @file_put_contents(__DIR__ . '/ingress_raw.log', sprintf("[%s] CT:%s LEN:%d Body:%s\n", date('c'), $ct, strlen($raw), substr($raw,0,1024)), FILE_APPEND ); // 2) Try JSON first when appropriate, else fall back to form $timestamp = null; $data = null; $gateway = null; if (strpos($ct, 'application/json') !== false && $raw !== '') { $j = json_decode($raw, true); if (is_array($j)) { $timestamp = $j['timestamp'] ?? null; $data = $j['data'] ?? null; $gateway = $j['gateway_id'] ?? null; } } // Fallback: x-www-form-urlencoded / multipart if ($timestamp === null && !empty($_POST)) { $timestamp = $_POST['timestamp'] ?? $timestamp; $data = $_POST['data'] ?? $data; $gateway = $_POST['gateway_id'] ?? $gateway; } // Last-ditch attempt: some clients send JSON but forget the header if ($timestamp === null && $raw !== '') { $j = json_decode($raw, true); if (is_array($j)) { $timestamp = $j['timestamp'] ?? $timestamp; $data = $j['data'] ?? $data; $gateway = $j['gateway_id'] ?? $gateway; } } // Defaults / validation $gateway = $gateway ?: 'UNKNOWN'; evaluateGatewayStatuses($OFFLINE_THRESHOLD); if ($timestamp && $data !== null) { $line = "[$timestamp][$gateway] " . (is_scalar($data) ? $data : json_encode($data)) . "\n"; append_file($log_file, $line); markHeartbeat($gateway); raiseOnlineAlertIfNeeded($gateway); $json_entry = [ 'timestamp' => $timestamp, 'gateway_id' => $gateway, 'data' => $data, 'is_abnormal' => false, 'created_at' => date('Y-m-d H:i:s') ]; if (isAbnormalData(is_scalar($data) ? (string)$data : json_encode($data))) { $alert_line = "[$timestamp][$gateway] ALERT: " . (is_scalar($data) ? $data : json_encode($data)) . "\n"; append_file($alerts_file, $alert_line); $json_entry['is_abnormal'] = true; $parsed = parseLogEntry($alert_line); appendToJsonFile($json_alerts_file, [ 'timestamp' => $timestamp, 'gateway_id' => $gateway, 'data' => $data, 'alert_type' => $parsed['type'], 'formatted_data' => $parsed['formatted'], 'created_at' => date('Y-m-d H:i:s') ], $MAX_JSON_ROWS); echo "ALERT_DETECTED:$gateway:" . (is_scalar($data) ? $data : json_encode($data)); exit; } appendToJsonFile($json_log_file, $json_entry, $MAX_JSON_ROWS); echo "Received and saved: $line"; exit; } http_response_code(400); echo "No valid data received."; exit; } ///////////////////////////////////////////////////////////////////////////////////////////////// OFFLINE $MAX_JSON_ROWS = 1000; /* -------------------- Utilities -------------------- */ function safe_read_json($filename){ if (!file_exists($filename)) return []; $c = @file_get_contents($filename); if ($c === false || $c === '') return []; $d = json_decode($c, true); return is_array($d) ? $d : []; } function safe_write_json($filename,$data){ $tmp = $filename . ".tmp"; $fh = @fopen($tmp,'w'); if (!$fh) return false; fwrite($fh, json_encode($data, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)); fclose($fh); return @rename($tmp,$filename); } function append_file($filename,$line){ $fh = @fopen($filename,'a'); if ($fh){ fwrite($fh,$line); fclose($fh); } } function appendToJsonFile($filename,$new_entry,$max_rows=1000){ $data = safe_read_json($filename); $data[] = $new_entry; if (count($data) > $max_rows){ $data = array_slice($data, -$max_rows); } safe_write_json($filename,$data); } /* -------------------- Status store -------------------- */ function getAllGatewayStatuses(){ global $status_file; $s = safe_read_json($status_file); return is_array($s) ? $s : []; } function setGatewayStatus($gateway,$fields){ global $status_file; $statuses = getAllGatewayStatuses(); if (!isset($statuses[$gateway])){ $statuses[$gateway] = [ 'gateway_id'=>$gateway, 'status'=>'unknown', 'last_seen_epoch'=>null, 'last_seen_iso'=>null, 'offline_alert_sent'=>false, 'online_alert_sent'=>false, 'notes'=>'' ]; } foreach($fields as $k=>$v){ $statuses[$gateway][$k]=$v; } safe_write_json($status_file,$statuses); return $statuses[$gateway]; } function markHeartbeat($gateway,$epoch=null){ $now = $epoch ?? time(); return setGatewayStatus($gateway,[ 'last_seen_epoch'=>$now, 'last_seen_iso'=>gmdate('Y-m-d\TH:i:s\Z',$now) ]); } function raiseOfflineAlertIfNeeded($gateway,$reason='No signal > threshold'){ global $alerts_file,$json_alerts_file; $st = getAllGatewayStatuses()[$gateway] ?? null; if (!$st) return; $need = ($st['status']!=='offline') || ($st['status']==='offline' && empty($st['offline_alert_sent'])); if (!$need) return; setGatewayStatus($gateway,[ 'status'=>'offline', 'offline_alert_sent'=>true, 'online_alert_sent'=>false, 'notes'=>$reason ]); $ts = date('Y-m-d H:i:s'); append_file($alerts_file, "[$ts][$gateway] ALERT: GATEWAY_OFFLINE ($reason)\n"); appendToJsonFile($json_alerts_file, [ 'timestamp'=>$ts, 'gateway_id'=>$gateway, 'data'=>"GATEWAY_OFFLINE", 'alert_type'=>'fault', 'formatted_data'=>"Gateway $gateway OFFLINE – $reason", 'created_at'=>$ts ]); } function raiseOnlineAlertIfNeeded($gateway){ global $alerts_file,$json_alerts_file; $st = getAllGatewayStatuses()[$gateway] ?? null; if (!$st) return; $need = ($st['status']!=='online') || ($st['status']==='online' && empty($st['online_alert_sent'])); if (!$need) return; if ($st['status']==='offline'){ $ts = date('Y-m-d H:i:s'); append_file($alerts_file, "[$ts][$gateway] ALERT: GATEWAY_ONLINE (signal restored)\n"); appendToJsonFile($json_alerts_file, [ 'timestamp'=>$ts, 'gateway_id'=>$gateway, 'data'=>"GATEWAY_ONLINE", 'alert_type'=>'normal', 'formatted_data'=>"Gateway $gateway BACK ONLINE – signal restored", 'created_at'=>$ts ]); } setGatewayStatus($gateway,[ 'status'=>'online', 'offline_alert_sent'=>false, 'online_alert_sent'=>true, 'notes'=>'Signal received' ]); } function evaluateGatewayStatuses($threshold=60){ $now = time(); $statuses = getAllGatewayStatuses(); foreach ($statuses as $gw=>$st){ $last = $st['last_seen_epoch'] ?? null; if ($last===null) continue; $age = $now - $last; if ($age > $threshold){ raiseOfflineAlertIfNeeded($gw, "No signal for $age s (threshold $threshold s)"); } else { if (($st['status'] ?? 'unknown') !== 'online'){ raiseOnlineAlertIfNeeded($gw); } } } } /* -------------------- Abnormality + parsing -------------------- */ function isAbnormalData($data){ $t = trim($data); if (strtolower($t)==='normal') return false; if (strpos(strtolower($t),'reset panel')!==false) return true; if (strpos($t,'Fire')!==false || strpos($t,'Fault')!==false) return true; $dl = strtolower($t); foreach(['error','fail','critical','warning','exception','timeout','disconnect','offline','crash','abort','deny','reject','invalid','corrupt','breach','attack','unauthorized','reset panel','reset','restart','reboot','malfunction'] as $p){ if (strpos($dl,$p)!==false) return true; } if (preg_match('/(\d+)/',$data,$m)){ $v=intval($m[1]); if ($v>1000||$v<-100) return true; } if (preg_match('/(.)\1{5,}/',$data)) return true; if (strlen($t)<2 && $t!=='') return true; return false; } function parseLogEntry($line){ $default=['timestamp'=>'','gateway'=>'UNKNOWN','data'=>$line,'type'=>'unknown','formatted'=>$line]; if (!preg_match('/\[(.*?)\]\[(.*?)\] (.*)/',$line,$m)) return $default; $timestamp=$m[1]; $gateway=$m[2]; $data=$m[3]; $type='alert'; $formatted=$data; if (preg_match('/\@(\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2}:\d{2})\s+(\d+)&([^\.]+)\.i&Fire\.(ii\d+il)[^&]*&([^\.]+)/',$data,$fm)){ $type='fire'; $date=$fm[1]; $time=substr($fm[2],0,5); $full=$fm[3]; $extra=$fm[4]; $ecode=substr($fm[5],2,-2); $loc=trim($fm[6]); $formatted="$date, $time, Fire, $ecode, ".$loc.$extra.", $full"; } elseif (preg_match('/\@(\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2}:\d{2})\s+(\d+)&([^\.]+)\.i&Fault\.(ii\d+il)[^&]*&([^\.]+)/',$data,$fm)){ $type='fault'; $date=$fm[1]; $time=substr($fm[2],0,5); $full=$fm[3]; $extra=$fm[4]; $ecode=substr($fm[5],2,-2); $loc=trim($fm[6]); $formatted="$date, $time, Fault, $ecode, ".$loc.$extra.", $full"; } elseif (stripos($data,'GATEWAY_OFFLINE')!==false){ $type='fault'; $formatted="Gateway Offline (no signal)"; } elseif (stripos($data,'GATEWAY_ONLINE')!==false){ $type='normal'; $formatted="Gateway Back Online"; } return ['timestamp'=>$timestamp,'gateway'=>$gateway,'data'=>$data,'formatted'=>$formatted,'type'=>$type]; } /* -------------------- API: GET JSON -------------------- */ if (isset($_GET['api']) && $_GET['api']==='json'){ header('Content-Type: application/json'); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); $type = $_GET['type'] ?? 'all'; $limit = intval($_GET['limit'] ?? 100); evaluateGatewayStatuses($OFFLINE_THRESHOLD); $result = ['ok'=>true,'server_time'=>date('Y-m-d H:i:s')]; if ($type==='all' || $type==='logs'){ $logs = safe_read_json($json_log_file); if (!is_array($logs)) $logs=[]; $result['logs'] = array_slice($logs, -$limit); } if ($type==='all' || $type==='alerts'){ $alerts = safe_read_json($json_alerts_file); if (!is_array($alerts)) $alerts=[]; // normalize to array if (!empty($alerts) && array_keys($alerts)!==range(0,count($alerts)-1)) $alerts = array_values($alerts); $result['alerts'] = array_slice($alerts, -$limit); } if ($type==='all' || $type==='status'){ $result['status'] = getAllGatewayStatuses(); } echo json_encode($result); exit; } /* -------------------- API: POST ingest -------------------- */ if ($_SERVER['REQUEST_METHOD']==='POST'){ $timestamp = $_POST['timestamp'] ?? ''; $data = $_POST['data'] ?? ''; $gateway = $_POST['gateway_id'] ?? 'UNKNOWN'; evaluateGatewayStatuses($OFFLINE_THRESHOLD); if ($timestamp && $data){ $line = "[$timestamp][$gateway] $data\n"; append_file($log_file,$line); markHeartbeat($gateway); raiseOnlineAlertIfNeeded($gateway); $json_entry = [ 'timestamp'=>$timestamp,'gateway_id'=>$gateway,'data'=>$data, 'is_abnormal'=>false,'created_at'=>date('Y-m-d H:i:s') ]; if (isAbnormalData($data)){ $alert_line = "[$timestamp][$gateway] ALERT: $data\n"; append_file($alerts_file, $alert_line); $json_entry['is_abnormal'] = true; $parsed = parseLogEntry($alert_line); appendToJsonFile($json_alerts_file, [ 'timestamp'=>$timestamp,'gateway_id'=>$gateway,'data'=>$data, 'alert_type'=>$parsed['type'],'formatted_data'=>$parsed['formatted'], 'created_at'=>date('Y-m-d H:i:s') ], $MAX_JSON_ROWS); echo "ALERT_DETECTED:$gateway:$data"; exit; } appendToJsonFile($json_log_file,$json_entry,$MAX_JSON_ROWS); echo "Received and saved: $line"; exit; } echo "No valid data received."; exit; } /* default no content */ http_response_code(204); ////////////////////////////////////////////////////////////////////////////////////// // Format a reset timestamp to MYT, **treating the input as local (MYT)** function formatResetToMYT(ts){ if (!ts) return ''; // Case 1: "dd.mm.yyyy HH:MM:SS" (your panel style) let m = String(ts).trim().match(/^(\d{2})\.(\d{2})\.(\d{4})\s+(\d{2}):(\d{2}):(\d{2})$/); if (m){ const [, d, mo, y, h, mi, s] = m.map(Number); const dt = new Date(y, mo-1, d, h, mi, s); // <-- treat as local time (no UTC conversion) return new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Kuala_Lumpur', year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false }).format(dt) + ' MYT'; } // Case 2: "yyyy-mm-dd HH:MM:SS" β†’ also treat as local m = String(ts).trim().match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$/); if (m){ const [, y, mo, d, h, mi, s] = m.map(Number); const dt = new Date(y, mo-1, d, h, mi, s); return new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Kuala_Lumpur', year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false }).format(dt) + ' MYT'; } // Fallback: let the browser parse, then show as MYT const parsed = Date.parse(ts); if (!isNaN(parsed)){ return new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Kuala_Lumpur', year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false }).format(new Date(parsed)) + ' MYT'; } return String(ts); // if we can't parse, show raw } /////////////////////////////////////////////////////////////////////////////////////// :root{ --safe-top: env(safe-area-inset-top, 0px); } /* put content below the status bar when overlaying */ body{ padding-top: 0 } /* keep zero since you use a fixed navbar */ /* shift the fixed header and panels down by the safe area */ .navbar{ top: var(--safe-top); } /* the slide-in submenu starts below the status bar */ .submenu{ top: var(--safe-top); height: calc(100% - var(--safe-top)); } /* the floating avatar/menu button should sit below the status bar too */ .toggle-menu-btn{ top: calc(20px + var(--safe-top)); } /* your scrollable container compensates for navbar height + safe area */ .main-container{ margin-top: calc(56px + var(--safe-top)); } /* (Optional) if you ever fix anything else to top:0, add the same offset */ /////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////// :root{ --safe-top: env(safe-area-inset-top, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px); --appbar-h: 64px; /* tweak if your header is taller/shorter */ --bottomnav-h: 72px; /* your bottom nav height */ } /* HEADER: from sticky -> fixed, and lift above everything */ .appbar{ position: fixed; /* was: sticky */ top: 0; left: 0; right: 0; z-index: 1030; /* keep your gradient, padding, etc. */ padding-top: calc(0.8rem + var(--safe-top)); /* keeps content under the status bar/notch */ min-height: calc(var(--appbar-h) + var(--safe-top)); } /* PAGE CONTENT: give it room under the fixed header and above the fixed bottom nav */ main, .main-container{ /* remove margin-top if you had it */ margin-top: 0; /* add padding so content doesn't sit under the bars */ padding-top: calc(var(--appbar-h) + 12px + var(--safe-top)); padding-bottom: calc(var(--bottomnav-h) + 12px + var(--safe-bottom)); } /* If you have a custom scroller, it’s fine to keep it */ .main-container{ overflow-y: auto; /* or scroll */ height: 100vh; /* optional; or your existing calc */ }