/* 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 */
}