Files
car/monitor.php
T
2026-06-07 00:33:58 +09:00

1965 lines
82 KiB
PHP

<?php
declare(strict_types=1);
$carSecretFile = '/home/seo/secret/car.php';
if (!is_file($carSecretFile)) {
throw new RuntimeException('Missing car secret config: ' . $carSecretFile);
}
$carSecretConfig = require $carSecretFile;
$carDbConfig = is_array($carSecretConfig['db'] ?? null) ? $carSecretConfig['db'] : [];
define('DB_HOST', (string)($carDbConfig['host'] ?? '127.0.0.1'));
define('DB_NAME', (string)($carDbConfig['name'] ?? 'car'));
define('DB_USER', (string)($carDbConfig['user'] ?? 'car'));
define('DB_PASS', (string)($carDbConfig['pass'] ?? ''));
define('DB_CHARSET', (string)($carDbConfig['charset'] ?? 'utf8mb4'));
const LOG_LIMIT = 300;
const CHART_LIMIT = 500;
const DATA_MONTHLY_INCLUDED_BYTES = 104857600; // 100MB
const DATA_BILLING_UNIT_BYTES = 512; // 0.5KB
const DATA_BILLING_UNIT_KRW = 0.011;
const DATA_MONTHLY_BASE_FEE_KRW = 5500;
const DATA_MONTHLY_ADDON_FEE_KRW = 2200;
const RECEIVE_GAP_LIMIT_SEC = 60;
// SKT T world "잔여기본통화 정보" 기준 현재월 누적값.
// carrier_total_bytes / meter_adjusted_bytes 로 보정 배율을 역산해 로컬 송수신 계측값에 적용한다.
const DATA_CURRENT_BILLING_USAGE_BYTES = [
'2026-06' => ['total_bytes' => 26937856, 'meter_adjusted_bytes' => 13240579, 'included_bytes' => 104857600, 'coupon_registered_at' => null],
];
function car_db(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$dsn = 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=' . DB_CHARSET;
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function seconds_from_ts(?string $ts): ?int
{
if (!$ts) {
return null;
}
$time = strtotime($ts);
if ($time === false) {
return null;
}
return max(0, time() - $time);
}
function current_state_duration(PDO $pdo, ?array $latest): int
{
if (!$latest || empty($latest['ts'])) {
return 0;
}
$driving = (int)($latest['driving'] ?? 0);
$engine = (int)($latest['engine'] ?? 0);
$remoteRunning = (int)($latest['remote_start_running'] ?? 0);
$remotePreparing = (int)($latest['remote_start_preparing'] ?? 0);
$latestTs = strtotime((string)$latest['ts']);
if ($latestTs === false) {
return 0;
}
$stmt = $pdo->prepare(""
. "SELECT ts, driving, engine, remote_start_running, remote_start_preparing "
. "FROM car_status "
. "WHERE id='car' "
. "AND ts <= :latest_ts "
. "ORDER BY ts DESC LIMIT 50000"
);
$stmt->execute([
':latest_ts' => date('Y-m-d H:i:s', $latestTs),
]);
$startTs = $latestTs;
$newerTs = $latestTs;
while ($row = $stmt->fetch()) {
$rowTs = strtotime((string)($row['ts'] ?? ''));
if ($rowTs === false) {
continue;
}
if (($newerTs - $rowTs) > RECEIVE_GAP_LIMIT_SEC) {
break;
}
$sameState =
(int)($row['driving'] ?? 0) === $driving &&
(int)($row['engine'] ?? 0) === $engine &&
(int)($row['remote_start_running'] ?? 0) === $remoteRunning &&
(int)($row['remote_start_preparing'] ?? 0) === $remotePreparing;
if (!$sameState) {
break;
}
$startTs = $rowTs;
$newerTs = $rowTs;
}
$now = time();
$endTs = ($now - $latestTs) > RECEIVE_GAP_LIMIT_SEC ? $latestTs : $now;
return max(0, $endTs - $startTs);
}
function current_engine_state_duration(PDO $pdo, ?array $latest): int
{
if (!$latest || empty($latest['ts'])) {
return 0;
}
$engine = (int)($latest['engine'] ?? 0);
$latestTs = strtotime((string)$latest['ts']);
if ($latestTs === false) {
return 0;
}
$latestTsText = date('Y-m-d H:i:s', $latestTs);
$oppositeEngine = $engine === 1 ? 0 : 1;
$stmt = $pdo->prepare(""
. "SELECT ts "
. "FROM car_status "
. "WHERE id='car' AND engine = :engine AND ts <= :latest_ts "
. "ORDER BY ts DESC LIMIT 1"
);
$stmt->execute([
':engine' => $oppositeEngine,
':latest_ts' => $latestTsText,
]);
$oppositeTs = $stmt->fetchColumn();
if ($oppositeTs) {
$stmt = $pdo->prepare(""
. "SELECT ts "
. "FROM car_status "
. "WHERE id='car' AND engine = :engine AND ts > :opposite_ts AND ts <= :latest_ts "
. "ORDER BY ts ASC LIMIT 1"
);
$stmt->execute([
':engine' => $engine,
':opposite_ts' => (string)$oppositeTs,
':latest_ts' => $latestTsText,
]);
$startTsText = $stmt->fetchColumn() ?: $latestTsText;
} else {
$stmt = $pdo->prepare(""
. "SELECT ts "
. "FROM car_status "
. "WHERE id='car' AND engine = :engine AND ts <= :latest_ts "
. "ORDER BY ts ASC LIMIT 1"
);
$stmt->execute([
':engine' => $engine,
':latest_ts' => $latestTsText,
]);
$startTsText = $stmt->fetchColumn() ?: $latestTsText;
}
$startTs = strtotime((string)$startTsText);
if ($startTs === false) {
return 0;
}
return max(0, time() - $startTs);
}
function latest_tcp_usage(PDO $pdo): ?array
{
$stmt = $pdo->query(""
. "SELECT ts, source, cmd, sent_bytes, received_bytes, total_bytes, tcp_ok, tcp_error "
. "FROM car_data_usage "
. "WHERE id='car' "
. "ORDER BY ts DESC LIMIT 1"
);
$row = $stmt->fetch();
return $row ?: null;
}
function monthly_data_usage(PDO $pdo): array
{
$now = new DateTimeImmutable('now');
$monthStart = $now->modify('first day of this month')->setTime(0, 0, 0);
$nextMonthStart = $monthStart->modify('first day of next month');
$stmt = $pdo->prepare(""
. "SELECT ts, source, cmd, sent_bytes, received_bytes, total_bytes, tcp_ok, tcp_error "
. "FROM car_data_usage "
. "WHERE id='car' AND ts >= :month_start AND ts < :next_month_start "
. "ORDER BY ts ASC"
);
$stmt->execute([
':month_start' => $monthStart->format('Y-m-d H:i:s'),
':next_month_start' => $nextMonthStart->format('Y-m-d H:i:s'),
]);
$usageRows = $stmt->fetchAll();
$usage = calibrated_usage_rows($usageRows);
$todayStart = $now->setTime(0, 0, 0);
$tomorrowStart = $todayStart->modify('+1 day');
$todayUsage = calibrated_usage_rows(array_values(array_filter($usageRows, function (array $row) use ($todayStart, $tomorrowStart): bool {
$ts = strtotime((string)($row['ts'] ?? ''));
return $ts !== false && $ts >= $todayStart->getTimestamp() && $ts < $tomorrowStart->getTimestamp();
})));
$sentBytes = $usage['sent_bytes'];
$receivedBytes = $usage['received_bytes'];
$totalBytes = $usage['total_bytes'];
$adjustedTotalBytes = $usage['adjusted_total_bytes'];
$remainingBytes = max(DATA_MONTHLY_INCLUDED_BYTES - $adjustedTotalBytes, 0);
$overBytes = max($adjustedTotalBytes - DATA_MONTHLY_INCLUDED_BYTES, 0);
$overUnits = $overBytes > 0 ? (int)ceil($overBytes / DATA_BILLING_UNIT_BYTES) : 0;
$overFeeKrw = $overUnits * DATA_BILLING_UNIT_KRW;
$monthSeconds = max(1, $nextMonthStart->getTimestamp() - $monthStart->getTimestamp());
$remainingSeconds = max(0, $nextMonthStart->getTimestamp() - $now->getTimestamp());
$daysInMonth = (int)$monthStart->format('t');
$firstUsageTs = $usage['first_usage_ts'];
$projectionStart = $firstUsageTs ? strtotime((string)$firstUsageTs) : $monthStart->getTimestamp();
if ($projectionStart === false || $projectionStart < $monthStart->getTimestamp()) {
$projectionStart = $monthStart->getTimestamp();
}
$measuredSeconds = max(1, $now->getTimestamp() - $projectionStart);
$currentBilling = current_billing_usage($monthStart->format('Y-m'));
$billingScale = billing_meter_scale($currentBilling);
$billingCurrentBytes = (int)round($adjustedTotalBytes * $billingScale);
$projectedBytes = (int)round($adjustedTotalBytes / $measuredSeconds * $monthSeconds);
$billingProjectedBytes = $adjustedTotalBytes > 0
? max((int)round($projectedBytes * $billingScale), $billingCurrentBytes)
: 0;
$calibration = billing_calibration($currentBilling, $billingScale);
if ($billingProjectedBytes <= 0) {
$billingProjectedBytes = $calibration['projected_total_bytes'];
}
$projectedOverBytes = max($projectedBytes - DATA_MONTHLY_INCLUDED_BYTES, 0);
$projectedOverUnits = $projectedOverBytes > 0 ? (int)ceil($projectedOverBytes / DATA_BILLING_UNIT_BYTES) : 0;
$projectedOverFeeKrw = $projectedOverUnits * DATA_BILLING_UNIT_KRW;
$billingCurrentOverBytes = max($billingCurrentBytes - DATA_MONTHLY_INCLUDED_BYTES, 0);
$billingCurrentOverUnits = $billingCurrentOverBytes > 0 ? (int)ceil($billingCurrentOverBytes / DATA_BILLING_UNIT_BYTES) : 0;
$billingCurrentOverFeeKrw = $billingCurrentOverUnits * DATA_BILLING_UNIT_KRW;
$billingProjectedOverBytes = max($billingProjectedBytes - DATA_MONTHLY_INCLUDED_BYTES, 0);
$billingProjectedOverUnits = $billingProjectedOverBytes > 0 ? (int)ceil($billingProjectedOverBytes / DATA_BILLING_UNIT_BYTES) : 0;
$billingProjectedOverFeeKrw = $billingProjectedOverUnits * DATA_BILLING_UNIT_KRW;
$fixedFeeKrw = DATA_MONTHLY_BASE_FEE_KRW + DATA_MONTHLY_ADDON_FEE_KRW;
$dailyRecommendedBytes = (int)floor(DATA_MONTHLY_INCLUDED_BYTES / max(1, $daysInMonth));
$estimatedTodayBytes = (int)round($todayUsage['adjusted_total_bytes'] * $billingScale);
return [
'month' => $monthStart->format('Y-m'),
'month_start' => $monthStart->format('Y-m-d H:i:s'),
'next_month_start' => $nextMonthStart->format('Y-m-d H:i:s'),
'days_remaining' => (int)floor($remainingSeconds / 86400),
'days_in_month' => $daysInMonth,
'included_bytes' => DATA_MONTHLY_INCLUDED_BYTES,
'daily_recommended_bytes' => $dailyRecommendedBytes,
'estimated_today_bytes' => $estimatedTodayBytes,
'meter_estimated_today_bytes' => $todayUsage['adjusted_total_bytes'],
'sent_bytes' => $sentBytes,
'received_bytes' => $receivedBytes,
'total_bytes' => $totalBytes,
'adjusted_total_bytes' => $adjustedTotalBytes,
'billing_sent_bytes' => (int)round($sentBytes * $billingScale),
'billing_received_bytes' => (int)round($receivedBytes * $billingScale),
'billing_adjusted_failure_bytes' => (int)round($usage['adjusted_failure_bytes'] * $billingScale),
'connected_failure_count' => $usage['connected_failure_count'],
'connect_failure_count' => $usage['connect_failure_count'],
'receive_gap_excluded_count' => $usage['receive_gap_excluded_count'],
'adjusted_failure_bytes' => $usage['adjusted_failure_bytes'],
'today_total_bytes' => $todayUsage['total_bytes'],
'today_adjusted_total_bytes' => $todayUsage['adjusted_total_bytes'],
'today_sample_count' => $todayUsage['sample_count'],
'today_connected_failure_count' => $todayUsage['connected_failure_count'],
'today_connect_failure_count' => $todayUsage['connect_failure_count'],
'today_receive_gap_excluded_count' => $todayUsage['receive_gap_excluded_count'],
'remaining_bytes' => $remainingBytes,
'over_bytes' => $overBytes,
'over_fee_raw_krw' => round($overFeeKrw, 2),
'over_fee_krw' => floor_krw_10($overFeeKrw),
'base_fee_krw' => DATA_MONTHLY_BASE_FEE_KRW,
'addon_fee_krw' => DATA_MONTHLY_ADDON_FEE_KRW,
'fixed_fee_krw' => $fixedFeeKrw,
'estimated_service_fee_raw_krw' => round($fixedFeeKrw + $overFeeKrw, 2),
'estimated_service_fee_krw' => floor_krw_10($fixedFeeKrw + $overFeeKrw),
'projected_total_bytes' => $projectedBytes,
'projected_over_bytes' => $projectedOverBytes,
'projected_over_fee_raw_krw' => round($projectedOverFeeKrw, 2),
'projected_over_fee_krw' => floor_krw_10($projectedOverFeeKrw),
'projected_service_fee_raw_krw' => round($fixedFeeKrw + $projectedOverFeeKrw, 2),
'projected_service_fee_krw' => floor_krw_10($fixedFeeKrw + $projectedOverFeeKrw),
'billing_estimated_current_bytes' => $billingCurrentBytes,
'billing_meter_scale' => round($billingScale, 6),
'billing_remaining_bytes' => max(DATA_MONTHLY_INCLUDED_BYTES - $billingCurrentBytes, 0),
'billing_over_bytes' => $billingCurrentOverBytes,
'billing_over_fee_raw_krw' => round($billingCurrentOverFeeKrw, 2),
'billing_over_fee_krw' => floor_krw_10($billingCurrentOverFeeKrw),
'billing_projected_total_bytes' => $billingProjectedBytes,
'billing_projected_over_bytes' => $billingProjectedOverBytes,
'billing_projected_over_fee_raw_krw' => round($billingProjectedOverFeeKrw, 2),
'billing_projected_over_fee_krw' => floor_krw_10($billingProjectedOverFeeKrw),
'billing_projected_service_fee_raw_krw' => round($fixedFeeKrw + $billingProjectedOverFeeKrw, 2),
'billing_projected_service_fee_krw' => floor_krw_10($fixedFeeKrw + $billingProjectedOverFeeKrw),
'calibration' => $calibration,
'sample_count' => $usage['sample_count'],
'first_usage_ts' => $usage['first_usage_ts'],
'last_usage_ts' => $usage['last_usage_ts'],
'measured_seconds' => $measuredSeconds,
'billing_unit_bytes' => DATA_BILLING_UNIT_BYTES,
'billing_unit_krw' => DATA_BILLING_UNIT_KRW,
];
}
function bytes_to_mb(float $bytes): float
{
return $bytes / 1024 / 1024;
}
function floor_krw_10(float $value): int
{
return (int)floor(max(0.0, $value) / 10) * 10;
}
function calibrated_usage_rows(array $rows): array
{
$sentBytes = 0;
$receivedBytes = 0;
$totalBytes = 0;
$successfulTotalBytes = 0;
$successfulCount = 0;
$successByCmd = [];
$firstUsageTs = null;
$lastUsageTs = null;
foreach ($rows as $row) {
$cmd = (string)($row['cmd'] ?? '');
$sent = (int)($row['sent_bytes'] ?? 0);
$received = (int)($row['received_bytes'] ?? 0);
$total = (int)($row['total_bytes'] ?? 0);
$tcpOk = (int)($row['tcp_ok'] ?? 0) === 1;
$sentBytes += $sent;
$receivedBytes += $received;
$totalBytes += $total;
if ($firstUsageTs === null && !empty($row['ts'])) {
$firstUsageTs = (string)$row['ts'];
}
if (!empty($row['ts'])) {
$lastUsageTs = (string)$row['ts'];
}
if ($tcpOk && $total > 0) {
$successfulTotalBytes += $total;
$successfulCount++;
if (!isset($successByCmd[$cmd])) {
$successByCmd[$cmd] = ['total' => 0, 'count' => 0];
}
$successByCmd[$cmd]['total'] += $total;
$successByCmd[$cmd]['count']++;
}
}
$globalAverage = $successfulCount > 0 ? (int)round($successfulTotalBytes / $successfulCount) : 0;
$avgByCmd = [];
foreach ($successByCmd as $cmd => $stat) {
$avgByCmd[$cmd] = $stat['count'] > 0 ? (int)round($stat['total'] / $stat['count']) : $globalAverage;
}
$adjustedTotalBytes = 0;
$adjustedFailureBytes = 0;
$connectedFailureCount = 0;
$connectFailureCount = 0;
$receiveGapExcludedCount = 0;
$lastSuccessfulTs = null;
foreach ($rows as $row) {
$cmd = (string)($row['cmd'] ?? '');
$sent = (int)($row['sent_bytes'] ?? 0);
$total = (int)($row['total_bytes'] ?? 0);
$tcpOk = (int)($row['tcp_ok'] ?? 0) === 1;
$rowTs = strtotime((string)($row['ts'] ?? ''));
if ($tcpOk) {
$adjustedTotalBytes += $total;
if ($rowTs !== false) {
$lastSuccessfulTs = $rowTs;
}
continue;
}
if ($sent <= 0) {
// TCP 연결 자체가 실패한 건은 모뎀까지 명령이 전달되지 않은 것으로 보고 과금 추정에서 제외한다.
$connectFailureCount++;
continue;
}
$connectedFailureCount++;
if (
$lastSuccessfulTs === null ||
$rowTs === false ||
($rowTs - $lastSuccessfulTs) > RECEIVE_GAP_LIMIT_SEC
) {
$receiveGapExcludedCount++;
continue;
}
$estimatedBytes = $avgByCmd[$cmd] ?? $globalAverage;
if ($estimatedBytes <= 0) {
$estimatedBytes = max($sent, $total);
}
$adjustedTotalBytes += $estimatedBytes;
$adjustedFailureBytes += $estimatedBytes;
}
return [
'sent_bytes' => $sentBytes,
'received_bytes' => $receivedBytes,
'total_bytes' => $totalBytes,
'adjusted_total_bytes' => $adjustedTotalBytes,
'adjusted_failure_bytes' => $adjustedFailureBytes,
'connected_failure_count' => $connectedFailureCount,
'connect_failure_count' => $connectFailureCount,
'receive_gap_excluded_count' => $receiveGapExcludedCount,
'sample_count' => count($rows),
'success_count' => $successfulCount,
'success_average_bytes' => $globalAverage,
'first_usage_ts' => $firstUsageTs,
'last_usage_ts' => $lastUsageTs,
];
}
function current_billing_usage(string $month): ?array
{
if (!isset(DATA_CURRENT_BILLING_USAGE_BYTES[$month])) {
return null;
}
$row = DATA_CURRENT_BILLING_USAGE_BYTES[$month];
return [
'month' => $month,
'total_bytes' => (int)($row['total_bytes'] ?? 0),
'total_mb' => round(bytes_to_mb((int)($row['total_bytes'] ?? 0)), 6),
'meter_adjusted_bytes' => (int)($row['meter_adjusted_bytes'] ?? 0),
'included_bytes' => (int)($row['included_bytes'] ?? DATA_MONTHLY_INCLUDED_BYTES),
'included_mb' => round(bytes_to_mb((int)($row['included_bytes'] ?? DATA_MONTHLY_INCLUDED_BYTES)), 6),
'coupon_registered_at' => $row['coupon_registered_at'] ?? null,
];
}
function billing_meter_scale(?array $currentBilling): float
{
if (!$currentBilling) {
return 1.0;
}
$meterAdjustedBytes = (int)($currentBilling['meter_adjusted_bytes'] ?? 0);
if ($meterAdjustedBytes <= 0) {
return 1.0;
}
return (int)($currentBilling['total_bytes'] ?? 0) / $meterAdjustedBytes;
}
function billing_calibration(?array $currentBilling, float $billingScale): array
{
$estimatedCurrentBytes = $currentBilling ? (int)$currentBilling['total_bytes'] : 0;
$source = $currentBilling ? 'carrier_scaled_meter_usage' : 'meter_adjusted_usage';
return [
'mode' => $source,
'note' => $currentBilling
? 'Carrier portal current usage is used to reverse-calculate a billing scale for metered local sent/received bytes. Metered local usage remains available separately.'
: 'Metered usage includes successful requests and connected timeouts within 60 seconds of the last successful receive. Pure connect failures and longer receive gaps are excluded.',
'current_billing' => $currentBilling,
'billing_meter_scale' => round($billingScale, 6),
'projected_total_bytes' => 0,
'projected_total_mb' => 0.0,
'estimated_current_bytes' => $estimatedCurrentBytes,
'estimated_current_mb' => round(bytes_to_mb($estimatedCurrentBytes), 2),
'formula_fee_per_mb_krw' => round(1024 * 1024 / DATA_BILLING_UNIT_BYTES * DATA_BILLING_UNIT_KRW, 3),
];
}
function json_response(array $payload, int $status = 200): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') {
try {
$pdo = car_db();
$latest = $pdo->query(""
. "SELECT * FROM car_status "
. "WHERE id='car' "
. "ORDER BY ts DESC LIMIT 1"
)->fetch() ?: null;
$stmtLogs = $pdo->prepare(""
. "SELECT ts, cmd, boundary, engine, driving, battery_voltage, "
. "door_fl, door_fr, door_rl, door_rr, door_trunk, "
. "remote_start_preparing, remote_start_running, remote_start_remaining, "
. "hazard, raw_full, raw_trim "
. "FROM car_status "
. "WHERE id='car' "
. "ORDER BY ts DESC LIMIT :limit"
);
$stmtLogs->bindValue(':limit', LOG_LIMIT, PDO::PARAM_INT);
$stmtLogs->execute();
$logs = $stmtLogs->fetchAll();
$stmtChart = $pdo->prepare(""
. "SELECT ts, battery_voltage FROM ("
. " SELECT ts, battery_voltage "
. " FROM car_status "
. " WHERE id='car' AND battery_voltage > 0 "
. " ORDER BY ts DESC LIMIT :limit"
. ") AS recent ORDER BY ts ASC"
);
$stmtChart->bindValue(':limit', CHART_LIMIT, PDO::PARAM_INT);
$stmtChart->execute();
$chart = $stmtChart->fetchAll();
$ageSeconds = seconds_from_ts($latest['ts'] ?? null);
$latestTcp = latest_tcp_usage($pdo);
$isFastPollState = $latest ? (
(int)($latest['driving'] ?? 0) === 1 ||
(int)($latest['engine'] ?? 0) === 1 ||
(int)($latest['remote_start_preparing'] ?? 0) === 1 ||
(int)($latest['remote_start_running'] ?? 0) === 1
) : false;
$isStale = $ageSeconds !== null && $ageSeconds > 30;
$staleReason = null;
if ($isStale && $latestTcp && (int)$latestTcp['tcp_ok'] === 0) {
$staleReason = (string)($latestTcp['tcp_error'] ?? 'tcp_failed');
}
json_response([
'status' => 'success',
'data' => $latest,
'logs' => $logs,
'chart' => $chart,
'meta' => [
'age_seconds' => $ageSeconds,
'stale' => $isStale,
'stale_reason' => $staleReason,
'latest_tcp' => $latestTcp,
'state_duration' => current_engine_state_duration($pdo, $latest),
'poll_interval' => $isFastPollState ? 5 : 10,
'log_count' => count($logs),
'chart_count' => count($chart),
],
]);
} catch (Throwable $e) {
json_response([
'status' => 'error',
'message' => 'DB 조회 실패',
], 500);
}
}
if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
try {
json_response([
'status' => 'success',
'usage' => monthly_data_usage(car_db()),
]);
} catch (Throwable $e) {
json_response([
'status' => 'error',
'message' => '데이터 사용량 조회 실패',
], 500);
}
}
?>
<!DOCTYPE html>
<html lang="ko" data-bs-theme="light">
<head>
<script src="https://chaegeon.com/log/bancheck.min.js?_=<?php echo time(); ?>"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0f172a">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Car Monitor">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Car Monitor</title>
<link rel="icon" href="/car/assets/favicon.svg" type="image/svg+xml">
<link rel="shortcut icon" href="/car/favicon.ico">
<link rel="icon" href="/car/assets/icon-32.png" type="image/png" sizes="32x32">
<link rel="icon" href="/car/assets/icon-192.png" type="image/png" sizes="192x192">
<link rel="apple-touch-icon" href="/car/assets/apple-touch-icon.png">
<link rel="manifest" href="/car/assets/site.webmanifest">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Gowun+Dodum&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--font-main: 'Gowun Dodum', system-ui, -apple-system, sans-serif;
/* [Light Theme Variables] */
--bg-body: #f1f5f9;
--bg-gradient: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
--card-bg: rgba(255, 255, 255, 0.65);
--card-border: rgba(255, 255, 255, 0.8);
--card-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
--text-primary: #334155;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--table-head-text: #64748b;
--table-row-bg: rgba(255, 255, 255, 0.5);
--table-row-hover: rgba(255, 255, 255, 0.9);
--car-fill: #e2e8f0;
--car-stroke: #94a3b8;
--car-glass: rgba(0,0,0,0.1);
--accent-blue: #3b82f6;
--accent-green: #10b981;
--accent-red: #ef4444;
--accent-yellow: #f59e0b;
--box-bg-blue: rgba(59, 130, 246, 0.1);
--box-bg-green: rgba(16, 185, 129, 0.1);
--box-bg-red: rgba(239, 68, 68, 0.1);
--box-bg-yellow: rgba(245, 158, 11, 0.1);
--chart-line: #3b82f6;
--chart-grad-start: rgba(59, 130, 246, 0.4);
--chart-grad-end: rgba(59, 130, 246, 0);
/* Badge Colors (Light) */
--badge-bg-parked: rgba(148, 163, 184, 0.15);
--badge-text-parked: #64748b;
--badge-border-parked: rgba(148, 163, 184, 0.3);
--badge-bg-driving: rgba(16, 185, 129, 0.15);
--badge-text-driving: #10b981;
--badge-border-driving: rgba(16, 185, 129, 0.3);
}
[data-bs-theme="dark"] {
/* [Dark Theme Variables] */
--bg-body: #0b0e14;
--bg-gradient: radial-gradient(circle at 50% 0%, #1c2333 0%, #0b0e14 100%);
--card-bg: rgba(23, 28, 38, 0.6);
--card-border: rgba(255, 255, 255, 0.08);
--card-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--table-head-text: #94a3b8;
--table-row-bg: rgba(30, 41, 59, 0.4);
--table-row-hover: rgba(30, 41, 59, 0.8);
--car-fill: #1e293b;
--car-stroke: #475569;
--car-glass: rgba(0,0,0,0.5);
--box-bg-blue: rgba(59, 130, 246, 0.15);
--box-bg-green: rgba(16, 185, 129, 0.15);
--box-bg-red: rgba(239, 68, 68, 0.15);
--box-bg-yellow: rgba(245, 158, 11, 0.15);
--chart-line: #60a5fa;
--chart-grad-start: rgba(96, 165, 250, 0.5);
--chart-grad-end: rgba(96, 165, 250, 0);
/* Badge Colors (Dark) */
--badge-bg-parked: rgba(148, 163, 184, 0.2);
--badge-text-parked: #94a3b8;
--badge-border-parked: rgba(148, 163, 184, 0.2);
--badge-bg-driving: rgba(16, 185, 129, 0.2);
--badge-text-driving: #34d399;
--badge-border-driving: rgba(16, 185, 129, 0.2);
}
body {
font-family: var(--font-main);
background: var(--bg-body);
background-image: var(--bg-gradient);
background-attachment: fixed;
color: var(--text-primary);
min-height: 100vh;
transition: background 0.3s ease, color 0.3s ease;
}
/* Glassmorphism Cards */
.glass-card {
background: var(--card-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--card-border);
border-radius: 20px;
box-shadow: var(--card-shadow);
transition: all 0.3s ease;
overflow: hidden; /* Fix for header background corner bleeding */
}
/* Headers & Typography */
.dashboard-header {
display: flex; justify-content: space-between; align-items: center;
padding: 1.5rem 0.5rem; margin-bottom: 0.5rem;
}
.app-title {
font-size: 1.25rem; font-weight: 800; letter-spacing: -0.5px; color: var(--text-primary);
}
.label-text { font-size: 0.75rem; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 0.5rem; }
.value-text { font-size: 1.8rem; font-weight: 800; letter-spacing: -1px; line-height: 1.1; color: var(--text-primary); }
.unit-text { font-size: 0.9rem; font-weight: 600; color: var(--text-secondary); margin-left: 2px; }
.font-mono { font-family: 'SF Mono', 'Menlo', 'Monaco', monospace; }
.panel-header {
min-height: 55px;
display: flex;
align-items: center;
padding: 1rem;
border-bottom: 1px solid rgba(108, 117, 125, 0.1);
background: rgba(108, 117, 125, 0.1);
}
/* Icon Squares */
.icon-box {
width: 46px; height: 46px; border-radius: 14px;
display: flex; align-items: center; justify-content: center;
font-size: 1.25rem; margin-bottom: 1rem;
border: 1px solid transparent; transition: all 0.3s ease;
}
.c-blue { background: var(--box-bg-blue); color: var(--accent-blue); border-color: rgba(59, 130, 246, 0.2); }
.c-green { background: var(--box-bg-green); color: var(--accent-green); border-color: rgba(16, 185, 129, 0.2); }
.c-red { background: var(--box-bg-red); color: var(--accent-red); border-color: rgba(239, 68, 68, 0.2); }
.c-yellow { background: var(--box-bg-yellow); color: var(--accent-yellow); border-color: rgba(245, 158, 11, 0.2); }
/* Car Visuals */
.car-container {
position: relative;
height: 390px;
display: flex;
justify-content: center;
align-items: center;
}
.car-body {
fill: var(--car-fill); stroke: var(--car-stroke); stroke-width: 2;
transition: all 0.5s ease; filter: drop-shadow(0 10px 15px rgba(0,0,0,0.1));
}
.car-glass { fill: var(--car-glass); }
/* Door Indicators */
.door-line {
position: absolute; background: var(--accent-red);
box-shadow: 0 0 10px var(--accent-red);
opacity: 0; border-radius: 2px;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.door-line.open { opacity: 1; transform: scale(1.1); }
/* 더뉴 니로 2020 탑뷰 실루엣 기준 */
.dp-fl { top: 132px; left: calc(50% - 86px); width: 4px; height: 72px; }
.dp-fr { top: 132px; right: calc(50% - 86px); width: 4px; height: 72px; }
.dp-rl { top: 225px; left: calc(50% - 86px); width: 4px; height: 78px; }
.dp-rr { top: 225px; right: calc(50% - 86px); width: 4px; height: 78px; }
.dp-tk { bottom: 28px; left: 50%; transform: translateX(-50%); width: 86px; height: 4px; }
/* Headlights */
.headlight-beam {
position: absolute; top: -40px; left: 50%; transform: translateX(-50%);
width: 220px; height: 200px;
background: radial-gradient(ellipse at bottom, rgba(255, 255, 255, 0.8) 0%, rgba(255,255,255,0) 60%);
mix-blend-mode: screen; opacity: 0; transition: opacity 0.5s ease;
pointer-events: none; z-index: 0;
}
[data-bs-theme="light"] .headlight-beam { mix-blend-mode: hard-light; background: radial-gradient(ellipse at bottom, rgba(255, 200, 0, 0.5) 0%, rgba(255,255,255,0) 60%); }
.headlight-beam.on { opacity: 0.6; }
/* Engine Icon */
.engine-indicator {
position: absolute;
top: 46%;
left: 50%;
transform: translate(-50%, -50%);
}
.engine-indicator.active {
color: var(--accent-yellow);
filter: drop-shadow(0 0 15px var(--accent-yellow));
animation: spin-slow 4s linear infinite;
}
@keyframes spin-slow { 100% { transform: translate(-50%, -50%) rotate(360deg); } }
/* Log Table Styling */
.table-container { width: 100%; border-collapse: separate; border-spacing: 0 5px; }
.table-container th {
font-size: 0.7rem; color: var(--table-head-text); font-weight: 700;
padding: 0 10px 10px 10px; text-align: center; border: none;
}
.table-container td {
background: var(--table-row-bg);
padding: 10px 10px; font-size: 0.85rem; color: var(--text-primary);
border: none; vertical-align: middle; text-align: center;
transition: background 0.2s; letter-spacing: -0.2px;
word-break: keep-all;
}
.table-container td:first-child {
padding-left: 8px;
padding-right: 6px;
}
.table-container td:last-child {
padding-left: 16px;
padding-right: 14px;
}
.table-container td:nth-last-child(2) {
font-size: 0.9rem;
}
.table-container th,
.table-container td:nth-child(1),
.table-container td:nth-child(3),
.table-container td:nth-child(7),
.table-container td:nth-child(8) {
white-space: nowrap;
}
.table-container tr:hover td { background: var(--table-row-hover); }
.table-container tr td:first-child { border-top-left-radius: 10px; border-bottom-left-radius: 10px; }
.table-container tr td:last-child { border-top-right-radius: 10px; border-bottom-right-radius: 10px; }
/* Command Pills */
.cmd-pill {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 3px 10px; border-radius: 6px;
font-size: 0.75rem; font-weight: 600; min-width: 90px;
}
.detail-text {
color: var(--text-secondary);
font-family: 'SF Mono', monospace; font-size: 0.75rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 180px; margin: 0 auto;
}
.btn-theme-toggle {
width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--card-border);
background: var(--card-bg); color: var(--text-primary);
display: flex; align-items: center; justify-content: center; cursor: pointer;
transition: all 0.2s;
}
.btn-theme-toggle:hover { background: var(--bg-body); transform: scale(1.05); }
.btn-lang-toggle {
width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--card-border);
background: var(--card-bg); color: var(--text-primary);
display: flex; align-items: center; justify-content: center; cursor: pointer;
transition: all 0.2s;
}
.btn-lang-toggle:hover { background: var(--bg-body); transform: scale(1.05); }
@keyframes pulse-ring {
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); }
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
}
.status-active-pulse { animation: pulse-ring 2s infinite; }
/* Status Badge */
.status-badge {
padding: 0.35em 1em;
border-radius: 50rem;
font-weight: 500;
font-size: 0.85rem;
border: 1px solid transparent;
transition: all 0.3s ease;
}
.status-badge.parked {
background-color: var(--badge-bg-parked);
color: var(--badge-text-parked);
border-color: var(--badge-border-parked);
}
.status-badge.driving {
background-color: var(--badge-bg-driving);
color: var(--badge-text-driving);
border-color: var(--badge-border-driving);
}
.trim-link {
cursor: pointer;
}
.trim-link:hover {
color: var(--accent-blue);
}
.usage-card {
padding: 1.5rem 1.7rem;
}
.usage-layout {
display: grid;
grid-template-columns: minmax(240px, 305px) 1fr;
gap: 1.3rem;
align-items: center;
}
.usage-head {
display: flex;
align-items: center;
gap: 1rem;
min-width: 0;
}
.usage-icon {
flex: 0 0 auto;
margin-bottom: 0;
}
.usage-main {
min-width: 0;
}
.usage-meter {
height: 11px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.22);
overflow: hidden;
margin-bottom: 0.9rem;
}
.usage-meter-fill {
height: 100%;
width: 0%;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent-green), var(--accent-blue));
transition: width 0.25s ease, background 0.25s ease;
}
.usage-meter-fill.warning {
background: linear-gradient(90deg, var(--accent-yellow), var(--accent-red));
}
.usage-stat {
min-width: 0;
}
.usage-stat .font-mono {
white-space: nowrap;
}
.usage-metrics {
display: grid;
grid-template-columns: repeat(6, minmax(100px, 1fr));
gap: 0.8rem;
}
.usage-metric {
min-width: 0;
}
.usage-metric .label-text {
font-size: 0.7rem;
margin-bottom: 0.2rem;
letter-spacing: 0.45px;
white-space: nowrap;
}
.usage-metric .font-mono {
font-size: 1rem;
line-height: 1.2;
white-space: nowrap;
}
.usage-detail-line {
margin-top: 0.55rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-detail-line:empty {
display: none;
}
@media (max-width: 991.98px) {
.usage-layout {
grid-template-columns: 1fr;
}
.usage-metrics {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.55rem;
}
}
@media (max-width: 575.98px) {
.usage-card {
padding: 1rem;
}
.usage-metrics {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.45rem;
}
.usage-detail-line {
white-space: normal;
}
}
</style>
</head>
<body>
<div class="container py-3">
<div class="dashboard-header">
<div class="d-flex align-items-center gap-3">
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-flex align-items-center justify-content-center border border-primary border-opacity-25" style="width:48px; height:48px;">
<i class="fas fa-car"></i>
</div>
<div>
<div class="d-flex align-items-center gap-2">
<div class="app-title" data-i18n="appTitle">Car Monitor</div>
<span id="live-badge" class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-20 rounded-pill" style="font-size:0.6rem; vertical-align: top;">WAIT</span>
</div>
<div class="text-secondary" style="font-size: 0.8rem;" data-i18n="appSubtitle">Vehicle Status Dashboard</div>
</div>
</div>
<div class="d-flex align-items-center gap-3">
<div class="text-end d-none d-sm-block">
<div class="label-text mb-0" data-i18n="lastData">LAST DATA</div>
<div id="last-updated" class="font-mono fw-bold text-primary">--:--:--</div>
</div>
<button class="btn-lang-toggle shadow-sm" id="lang-btn" onclick="toggleLanguage()" title="Language">
<i class="fas fa-globe"></i>
</button>
<button class="btn-theme-toggle shadow-sm" id="theme-btn" onclick="toggleTheme()">
<i class="fas fa-moon"></i>
</button>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="glass-card h-100 p-4 position-relative">
<div class="icon-box c-blue"><i class="fas fa-car-battery"></i></div>
<div class="label-text" data-i18n="battery">Battery</div>
<div class="d-flex align-items-baseline">
<span id="val-battery" class="value-text">-</span><span id="val-battery-unit" class="unit-text">V</span>
</div>
<div id="val-battery-state" class="font-mono small mt-1 text-muted" style="font-size: 0.75rem; opacity: 0.8;">-</div>
<div style="height: 60px; position: absolute; bottom: 0; left: 0; right: 0; opacity: 0.5;">
<canvas id="chartBattery"></canvas>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="glass-card h-100 p-4">
<div class="icon-box c-yellow"><i class="fas fa-power-off"></i></div>
<div class="label-text" data-i18n="engine">Engine</div>
<div class="d-flex align-items-center gap-2 mb-2">
<span id="val-engine" class="value-text text-secondary">OFF</span>
</div>
<div id="val-state-timer" class="font-mono text-secondary mb-2" style="font-size: 0.85rem;">-</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="glass-card h-100 p-4">
<div class="icon-box c-red"><i class="fas fa-mobile-screen"></i></div>
<div class="label-text" data-i18n="remoteStart">Remote Start</div>
<div class="mb-1">
<span id="val-remote" class="value-text text-secondary">Standby</span>
</div>
<div class="d-flex align-items-center text-secondary font-mono small">
<i class="far fa-clock me-1"></i><span id="val-remote-time">--:--</span>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="glass-card h-100 p-4">
<div class="d-flex justify-content-between align-items-start">
<div class="icon-box c-green"><i class="fas fa-signal"></i></div>
<div class="text-end">
<div class="label-text mb-0" style="font-size:0.65rem;" data-i18n="age">AGE</div>
<div id="age-seconds" class="font-mono fw-bold text-muted">-</div>
</div>
</div>
<div class="label-text mt-1" data-i18n="pollTarget">Poll Target</div>
<div>
<span id="val-poll-interval" class="value-text">-</span><span id="val-poll-unit" class="unit-text">s</span>
</div>
<div id="val-freshness" class="font-mono small mt-1 text-muted" style="font-size: 0.75rem;">-</div>
</div>
</div>
</div>
<div class="glass-card usage-card mb-4">
<div class="usage-layout">
<div class="usage-head">
<div class="icon-box c-green usage-icon"><i class="fas fa-database"></i></div>
<div class="usage-stat">
<div id="usage-title" class="label-text mb-1">데이터 사용량</div>
<div class="d-flex align-items-baseline gap-2 flex-wrap">
<span id="usage-current" class="value-text">-</span>
<span id="usage-limit" class="unit-text">/ 100MB</span>
</div>
</div>
</div>
<div class="usage-main">
<div class="usage-meter">
<div id="usage-meter-fill" class="usage-meter-fill"></div>
</div>
<div class="usage-metrics small">
<div class="usage-metric">
<div class="label-text" data-i18n="dailyRecommended">하루 권장</div>
<div id="usage-daily-rec" class="font-mono fw-bold">-</div>
</div>
<div class="usage-metric">
<div class="label-text" data-i18n="todayUsage">오늘 사용</div>
<div id="usage-today" class="font-mono fw-bold">-</div>
</div>
<div class="usage-metric">
<div class="label-text" data-i18n="remainingUsage">잔여 사용량</div>
<div id="usage-remaining" class="font-mono fw-bold">-</div>
</div>
<div class="usage-metric">
<div class="label-text" data-i18n="estimatedUsage">예상 사용량</div>
<div id="usage-projected-total" class="font-mono fw-bold">-</div>
</div>
<div class="usage-metric">
<div class="label-text" data-i18n="estimatedOverFee">예상 초과</div>
<div id="usage-projected-over-fee" class="font-mono fw-bold">-</div>
</div>
<div class="usage-metric">
<div class="label-text" data-i18n="estimatedFee">예상 요금</div>
<div id="usage-projected-fee" class="font-mono fw-bold">-</div>
</div>
</div>
<div id="usage-detail" class="font-mono small text-muted usage-detail-line"></div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-lg-4">
<div class="glass-card h-100 d-flex flex-column">
<div class="panel-header">
<h6 class="mb-0 fw-bold small text-uppercase text-secondary"><i class="fas fa-car-side me-2"></i><span data-i18n="realtimeVisual">Real-time Visual</span></h6>
</div>
<div class="flex-grow-1 car-container">
<div id="visual-headlight" class="headlight-beam"></div>
<svg width="210" height="340" viewBox="0 0 320 640" xmlns="http://www.w3.org/2000/svg" style="z-index: 5;">
<defs>
<linearGradient id="bodyGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="rgba(255,255,255,0.22)"/>
<stop offset="100%" stop-color="rgba(0,0,0,0.05)"/>
</linearGradient>
<linearGradient id="glassGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="rgba(255,255,255,0.18)"/>
<stop offset="100%" stop-color="rgba(0,0,0,0.18)"/>
</linearGradient>
<filter id="softShadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="10" stdDeviation="10" flood-color="rgba(0,0,0,0.18)"/>
</filter>
</defs>
<!-- outer body : compact SUV / Niro-like top view -->
<path class="car-body" filter="url(#softShadow)" d="
M160,36
C205,38 244,54 266,92
C278,114 283,144 285,179
L287,250
L287,408
L285,480
C283,518 276,548 262,571
C239,608 200,622 160,624
C120,622 81,608 58,571
C44,548 37,518 35,480
L33,408
L33,250
L35,179
C37,144 42,114 54,92
C76,54 115,38 160,36
Z" />
<!-- body highlight -->
<path fill="url(#bodyGrad)" d="
M160,52
C199,54 232,68 251,100
C262,119 266,147 268,179
L270,250
L270,404
L268,474
C266,509 260,534 248,554
C228,586 194,600 160,602
C126,600 92,586 72,554
C60,534 54,509 52,474
L50,404
L50,250
L52,179
C54,147 58,119 69,100
C88,68 121,54 160,52
Z" opacity="0.38"/>
<!-- front windshield -->
<path class="car-glass" fill="url(#glassGrad)" d="
M92,122
C106,102 214,102 228,122
L238,192
C239,203 81,203 82,192
Z" />
<!-- roof / cabin -->
<path fill="rgba(0,0,0,0.10)" d="
M90,205
C96,194 224,194 230,205
L240,420
C242,444 78,444 80,420
Z" />
<!-- rear glass -->
<path class="car-glass" fill="url(#glassGrad)" d="
M96,440
C110,430 210,430 224,440
L216,512
C198,524 122,524 104,512
Z" />
<!-- bonnet hint -->
<path fill="rgba(255,255,255,0.10)" d="
M110,82
C126,74 194,74 210,82
C224,89 230,102 232,114
C212,108 108,108 88,114
C90,102 96,89 110,82
Z"/>
<!-- side rails / shoulder -->
<path fill="rgba(0,0,0,0.12)" d="M53,190 L64,188 L70,510 L58,505 Z" />
<path fill="rgba(0,0,0,0.12)" d="M267,190 L256,188 L250,510 L262,505 Z" />
<!-- mirrors -->
<path class="car-body" d="M50,205 L30,196 L30,228 L50,236 Z" />
<path class="car-body" d="M270,205 L290,196 L290,228 L270,236 Z" />
<!-- front lamp housings -->
<path fill="rgba(255,255,255,0.22)" d="M108,58 C126,50 194,50 212,58 L206,72 C190,66 130,66 114,72 Z"/>
<path fill="rgba(0,0,0,0.08)" d="M82,96 L238,96 L242,108 L78,108 Z"/>
<!-- rear bumper / hatch hint -->
<path fill="rgba(0,0,0,0.10)" d="
M92,548
C112,558 208,558 228,548
L220,578
C200,590 120,590 100,578
Z"/>
<!-- door seams center hints -->
<path stroke="rgba(0,0,0,0.12)" stroke-width="2" fill="none" d="M102,214 L218,214"/>
<path stroke="rgba(0,0,0,0.12)" stroke-width="2" fill="none" d="M98,396 L222,396"/>
<!-- wheel arch hints -->
<path fill="rgba(0,0,0,0.08)" d="M72,250 C66,278 66,372 72,400 L86,400 C80,372 80,278 86,250 Z"/>
<path fill="rgba(0,0,0,0.08)" d="M248,250 C254,278 254,372 248,400 L234,400 C240,372 240,278 234,250 Z"/>
</svg>
<i id="visual-engine-icon" class="fas fa-fan engine-indicator"></i>
<div id="door-fl" class="door-line dp-fl"></div>
<div id="door-fr" class="door-line dp-fr"></div>
<div id="door-rl" class="door-line dp-rl"></div>
<div id="door-rr" class="door-line dp-rr"></div>
<div id="door-trunk" class="door-line dp-tk"></div>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="glass-card h-100 d-flex flex-column">
<div class="panel-header">
<h6 class="mb-0 fw-bold small text-uppercase text-secondary"><i class="fas fa-list-ul me-2"></i><span data-i18n="statusLog">Status Log</span></h6>
</div>
<div class="p-3 flex-grow-1">
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
<table class="table-container">
<thead>
<tr>
<th width="10%" data-i18n="time">TIME</th>
<th width="9%" data-i18n="cmd">CMD</th>
<th width="7%" data-i18n="engShort">ENG</th>
<th width="8%" data-i18n="batShort">BAT</th>
<th width="11%" data-i18n="door">DOOR</th>
<th width="9%" data-i18n="remoteShort">REMOTE</th>
<th width="7%" data-i18n="hazShort">HAZ</th>
<th width="7%" data-i18n="age">AGE</th>
<th data-i18n="trim">TRIM</th>
</tr>
</thead>
<tbody id="log-table-body"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
const I18N = {
ko: {
appTitle: '차량 모니터',
appSubtitle: '차량 상태 대시보드',
lastData: '마지막 데이터',
battery: '배터리',
engine: '시동',
remoteStart: '원격 시동',
age: '경과',
pollTarget: '조회 주기',
realtimeVisual: '실시간 차량',
statusLog: '상태 기록',
time: '시간',
cmd: '명령',
engShort: '시동',
batShort: '전압',
door: '도어',
remoteShort: '원격시동',
hazShort: '비상등',
trim: '요약',
dailyRecommended: '권장 사용량',
todayUsage: '오늘 사용량',
remainingUsage: '잔여 사용량',
estimatedUsage: '예상 사용량',
estimatedOverFee: '예상 초과',
estimatedFee: '예상 요금',
rawFullData: '원본 데이터',
wait: '대기',
online: '정상',
stale: '지연',
error: '오류',
dataDelayed: '데이터 지연',
targetInterval: '정상 수신',
on: '켜짐',
off: '꺼짐',
yes: '주행',
no: '정차',
parked: '주차',
driving: '주행',
running: '실행 중',
preparing: '준비 중',
standby: '대기',
charging: '충전 중',
normal: '정상',
watch: '주의',
low: '낮음',
engineStateOnSentence: '시동이 {duration} 켜져있음',
engineStateOffSentence: '시동이 {duration} 꺼져있음',
dataUsageTitle: '{month} 데이터 사용량 / {days}일 남음',
languageTitle: '언어 변경',
rawView: '원본 보기',
cmdStatus: '상태',
cmdEngOff: '시동 끔',
cmdEngOn: '시동 켬',
cmdHazOn: '비상등 켬',
cmdHazOff: '비상등 끔',
cmdLock: '잠금',
cmdUnlock: '잠금 해제',
cmdTrunk: '트렁크',
secShort: '초',
dayShort: '일',
hourShort: '시간',
minShort: '분',
},
en: {
appTitle: 'Car Monitor',
appSubtitle: 'Vehicle Status Dashboard',
lastData: 'LAST DATA',
battery: 'Battery',
engine: 'Engine',
remoteStart: 'Remote Start',
age: 'Age',
pollTarget: 'Poll Target',
realtimeVisual: 'Real-time Visual',
statusLog: 'Status Log',
time: 'TIME',
cmd: 'CMD',
engShort: 'ENG',
batShort: 'BAT',
door: 'DOOR',
remoteShort: 'REMOTE',
hazShort: 'HAZ',
trim: 'TRIM',
dailyRecommended: 'Daily Rec.',
todayUsage: 'Today',
remainingUsage: 'Remain',
estimatedUsage: 'Est. Use',
estimatedOverFee: 'Est. Over',
estimatedFee: 'Est. Fee',
rawFullData: 'RAW FULL DATA',
wait: 'WAIT',
online: 'ONLINE',
stale: 'STALE',
error: 'ERROR',
dataDelayed: 'Data delayed',
targetInterval: 'Target interval',
on: 'ON',
off: 'OFF',
yes: 'YES',
no: 'NO',
parked: 'Parked',
driving: 'Driving',
running: 'Running',
preparing: 'Preparing',
standby: 'Standby',
charging: 'Charging',
normal: 'Normal',
watch: 'Watch',
low: 'Low',
engineStateOnSentence: 'Engine has been ON for {duration}',
engineStateOffSentence: 'Engine has been OFF for {duration}',
dataUsageTitle: '{month} Data Usage / {days}d left',
languageTitle: 'Change language',
rawView: 'View RAW',
cmdStatus: 'STATUS',
cmdEngOff: 'ENG OFF',
cmdEngOn: 'ENG ON',
cmdHazOn: 'HAZ ON',
cmdHazOff: 'HAZ OFF',
cmdLock: 'LOCK',
cmdUnlock: 'UNLOCK',
cmdTrunk: 'TRUNK',
secShort: 's',
dayShort: 'd',
hourShort: 'h',
minShort: 'm',
}
};
let currentLang = 'ko';
const DEFAULT_USAGE_REFRESH_INTERVAL_SEC = 10;
const CMD_MAP = {
se: { label: 'cmdStatus', icon: 'fa-search', bg: 'var(--box-bg-blue)', color: 'var(--accent-blue)' },
ef: { label: 'cmdEngOff', icon: 'fa-power-off', bg: 'rgba(148, 163, 184, 0.2)', color: 'var(--text-secondary)' },
en: { label: 'cmdEngOn', icon: 'fa-bolt', bg: 'var(--box-bg-red)', color: 'var(--accent-red)' },
hn: { label: 'cmdHazOn', icon: 'fa-triangle-exclamation', bg: 'var(--box-bg-yellow)', color: 'var(--accent-yellow)' },
hf: { label: 'cmdHazOff', icon: 'fa-minus', bg: 'rgba(148, 163, 184, 0.2)', color: 'var(--text-secondary)' },
dl: { label: 'cmdLock', icon: 'fa-lock', bg: 'rgba(139, 92, 246, 0.2)', color: '#a78bfa' },
du: { label: 'cmdUnlock', icon: 'fa-lock-open', bg: 'var(--box-bg-green)', color: 'var(--accent-green)' },
tu: { label: 'cmdTrunk', icon: 'fa-box-open', bg: 'var(--box-bg-yellow)', color: 'var(--accent-yellow)' }
};
let batteryChart = null;
let refreshTimer = null;
let usageTimer = null;
let usageRefreshIntervalSec = DEFAULT_USAGE_REFRESH_INTERVAL_SEC;
let usageInFlight = false;
document.addEventListener('DOMContentLoaded', () => {
initLanguage();
initTheme();
initChart();
fetchData();
refreshTimer = setInterval(fetchData, 1000);
fetchUsage();
});
function initLanguage() {
const saved = localStorage.getItem('lang');
const browser = (navigator.language || navigator.userLanguage || 'ko').toLowerCase();
currentLang = saved || (browser.startsWith('ko') ? 'ko' : 'en');
applyLanguage();
}
function toggleLanguage() {
currentLang = currentLang === 'ko' ? 'en' : 'ko';
localStorage.setItem('lang', currentLang);
applyLanguage();
fetchData();
}
function t(key) {
return I18N[currentLang]?.[key] || I18N.en[key] || key;
}
function applyLanguage() {
document.documentElement.setAttribute('lang', currentLang);
document.title = t('appTitle');
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.dataset.i18n);
});
const badge = document.getElementById('live-badge');
if (badge && ['WAIT', '대기'].includes(badge.textContent.trim())) {
badge.textContent = t('wait');
}
const pollUnit = document.getElementById('val-poll-unit');
if (pollUnit) pollUnit.textContent = t('secShort');
const langBtn = document.getElementById('lang-btn');
if (langBtn) {
langBtn.title = t('languageTitle');
langBtn.setAttribute('aria-label', t('languageTitle'));
}
}
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-bs-theme', savedTheme);
updateThemeIcon(savedTheme);
}
function toggleTheme() {
const html = document.documentElement;
const current = html.getAttribute('data-bs-theme');
const next = current === 'light' ? 'dark' : 'light';
html.setAttribute('data-bs-theme', next);
localStorage.setItem('theme', next);
updateThemeIcon(next);
updateChartTheme();
}
function updateThemeIcon(theme) {
const icon = document.querySelector('#theme-btn i');
icon.className = theme === 'dark' ? 'fas fa-sun text-warning' : 'fas fa-moon text-secondary';
}
function getChartColors() {
const style = getComputedStyle(document.body);
return {
line: style.getPropertyValue('--chart-line').trim(),
start: style.getPropertyValue('--chart-grad-start').trim(),
end: style.getPropertyValue('--chart-grad-end').trim()
};
}
function initChart() {
const ctx = document.getElementById('chartBattery').getContext('2d');
const colors = getChartColors();
const gradient = ctx.createLinearGradient(0, 0, 0, 60);
gradient.addColorStop(0, colors.start);
gradient.addColorStop(1, colors.end);
batteryChart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [{
data: [],
borderColor: colors.line,
borderWidth: 2,
pointRadius: 0,
tension: 0.3,
fill: true,
backgroundColor: gradient
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: { x: { display: false }, y: { display: false, min: 11.5, max: 15.0 } }
}
});
}
function updateChartTheme() {
if (!batteryChart) return;
const ctx = document.getElementById('chartBattery').getContext('2d');
const colors = getChartColors();
const gradient = ctx.createLinearGradient(0, 0, 0, 60);
gradient.addColorStop(0, colors.start);
gradient.addColorStop(1, colors.end);
batteryChart.data.datasets[0].borderColor = colors.line;
batteryChart.data.datasets[0].backgroundColor = gradient;
batteryChart.update();
}
function fetchData() {
fetch('?mode=ajax', { cache: 'no-store' })
.then(res => res.json())
.then(payload => {
if (payload.status !== 'success') return;
updateDashboard(payload.data, payload.meta || {});
updateLogs(payload.logs || []);
updateChart(payload.chart || []);
usageRefreshIntervalSec = normalizedUsageInterval(payload.meta?.poll_interval);
})
.catch(() => updateLiveBadge(false));
}
function fetchUsage() {
if (usageInFlight) {
return;
}
usageInFlight = true;
if (usageTimer) {
clearTimeout(usageTimer);
usageTimer = null;
}
fetch('?mode=usage', { cache: 'no-store' })
.then(res => res.json())
.then(payload => {
if (payload.status !== 'success') return;
updateUsage(payload.usage || null);
})
.catch(() => {})
.finally(() => {
usageInFlight = false;
scheduleUsageFetch();
});
}
function scheduleUsageFetch() {
if (usageTimer) {
clearTimeout(usageTimer);
}
usageTimer = setTimeout(fetchUsage, usageRefreshIntervalSec * 1000);
}
function normalizedUsageInterval(value) {
const seconds = Number(value);
return seconds > 0 ? seconds : DEFAULT_USAGE_REFRESH_INTERVAL_SEC;
}
function updateLiveBadge(ok, stale = false) {
const badge = document.getElementById('live-badge');
if (!ok) {
badge.textContent = t('error');
badge.className = 'badge bg-danger bg-opacity-10 text-danger border border-danger border-opacity-20 rounded-pill';
return;
}
if (stale) {
badge.textContent = t('stale');
badge.className = 'badge bg-warning bg-opacity-10 text-warning border border-warning border-opacity-20 rounded-pill';
return;
}
badge.textContent = t('online');
badge.className = 'badge bg-success bg-opacity-10 text-success border border-success border-opacity-20 rounded-pill';
}
function updateDashboard(data, meta) {
if (!data) {
updateLiveBadge(false);
return;
}
const age = Number(meta.age_seconds ?? 0);
const isStale = Boolean(meta.stale ?? age > 30);
const isReceiveBad = age >= 60;
updateLiveBadge(true, isStale);
document.getElementById('last-updated').textContent = data.ts ? String(data.ts).substring(11) : '--:--:--';
document.getElementById('age-seconds').textContent = formatAgeUnits(age);
document.getElementById('val-poll-interval').textContent = meta.poll_interval ?? '-';
const freshnessEl = document.getElementById('val-freshness');
freshnessEl.textContent = isReceiveBad ? t('dataDelayed') : t('targetInterval');
freshnessEl.className = isReceiveBad || isStale ? 'font-mono small mt-1 text-warning' : 'font-mono small mt-1 text-muted';
const ageEl = document.getElementById('age-seconds');
if (age > 60) ageEl.className = 'font-mono fw-bold text-danger';
else if (age > 30) ageEl.className = 'font-mono fw-bold text-warning';
else ageEl.className = 'font-mono fw-bold text-muted';
const voltage = parseFloat(data.battery_voltage || 0);
const batteryEl = document.getElementById('val-battery');
const batteryUnitEl = document.getElementById('val-battery-unit');
const batteryStateEl = document.getElementById('val-battery-state');
const batteryWarn = voltage > 0 && voltage <= 12.7;
batteryEl.textContent = voltage ? voltage.toFixed(1) : '-';
batteryEl.className = 'value-text';
batteryUnitEl.className = 'unit-text';
batteryStateEl.textContent = batteryLabel(voltage, parseInt(data.engine) === 1);
batteryStateEl.className = batteryWarn ? 'font-mono small mt-1 text-danger' : 'font-mono small mt-1 text-muted';
const isEngineOn = parseInt(data.engine) === 1;
const engineEl = document.getElementById('val-engine');
const visualEngine = document.getElementById('visual-engine-icon');
const visualHeadlight = document.getElementById('visual-headlight');
if (isEngineOn) {
engineEl.textContent = t('on');
engineEl.className = 'value-text text-warning';
visualEngine.classList.add('active');
visualHeadlight.classList.add('on');
} else {
engineEl.textContent = t('off');
engineEl.className = 'value-text text-secondary';
visualEngine.classList.remove('active');
visualHeadlight.classList.remove('on');
}
const duration = formatDuration(Number(meta.state_duration || 0));
const isEngineOnForLabel = parseInt(data.engine) === 1;
const sentenceKey = isEngineOnForLabel ? 'engineStateOnSentence' : 'engineStateOffSentence';
const sentence = t(sentenceKey).replace('{duration}', `<span class="fw-bold">${duration}</span>`);
document.getElementById('val-state-timer').innerHTML = sentence;
updateRemote(data);
updateDoors(data);
}
function updateUsage(usage) {
if (!usage) return;
const total = Number(usage.total_bytes || 0);
const billingTotal = Number(usage.billing_estimated_current_bytes ?? total);
const included = Number(usage.included_bytes || 0);
const percent = included > 0 ? Math.min((billingTotal / included) * 100, 100) : 0;
const fill = document.getElementById('usage-meter-fill');
const overBytes = Number(usage.billing_over_bytes ?? usage.over_bytes ?? 0);
const projectedOverBytes = Number(usage.billing_projected_over_bytes ?? usage.projected_over_bytes ?? 0);
const projectedTotal = Number(usage.billing_projected_total_bytes ?? usage.projected_total_bytes ?? billingTotal);
const daysRemaining = Number(usage.days_remaining || 0);
const estimatedOverFee = Number(usage.billing_projected_over_fee_krw ?? usage.projected_over_fee_krw ?? 0);
const estimatedFee = Number(usage.billing_projected_service_fee_krw ?? usage.projected_service_fee_krw ?? usage.estimated_service_fee_krw ?? 0);
document.getElementById('usage-title').textContent =
t('dataUsageTitle')
.replace('{month}', formatUsageMonth(usage.month))
.replace('{days}', String(daysRemaining));
document.getElementById('usage-current').textContent = formatPreciseMb(billingTotal);
document.getElementById('usage-limit').textContent = '/ ' + formatBytes(included);
document.getElementById('usage-daily-rec').textContent = formatPreciseMb(Number(usage.daily_recommended_bytes || 0));
document.getElementById('usage-today').textContent = formatPreciseMb(Number(usage.estimated_today_bytes || 0));
document.getElementById('usage-remaining').textContent = formatPreciseMb(Number(usage.billing_remaining_bytes ?? usage.remaining_bytes ?? 0));
document.getElementById('usage-projected-total').textContent = formatPreciseMb(projectedTotal);
document.getElementById('usage-projected-over-fee').textContent = formatKrw(estimatedOverFee);
document.getElementById('usage-projected-fee').textContent = formatKrw(estimatedFee);
document.getElementById('usage-detail').textContent = '';
fill.style.width = percent.toFixed(2) + '%';
fill.classList.toggle('warning', overBytes > 0 || projectedOverBytes > 0 || percent >= 85);
}
function updateRemote(data) {
const remoteEl = document.getElementById('val-remote');
if (parseInt(data.remote_start_running) === 1) {
remoteEl.textContent = t('running');
remoteEl.className = 'value-text text-danger status-active-pulse';
} else if (parseInt(data.remote_start_preparing) === 1) {
remoteEl.textContent = t('preparing');
remoteEl.className = 'value-text text-warning';
} else {
remoteEl.textContent = t('standby');
remoteEl.className = 'value-text text-secondary';
}
document.getElementById('val-remote-time').textContent = data.remote_start_remaining || '--:--';
}
function updateDoors(data) {
['fl', 'fr', 'rl', 'rr', 'trunk'].forEach(pos => {
const el = document.getElementById('door-' + pos);
if (!el) return;
if (parseInt(data['door_' + pos]) === 1) el.classList.add('open');
else el.classList.remove('open');
});
}
function updateLogs(logs) {
const tbody = document.getElementById('log-table-body');
const html = logs.map(log => {
const cmdCode = String(log.cmd || '').toLowerCase();
const cmd = CMD_MAP[cmdCode] || { label: '', icon: 'fa-terminal', bg: 'rgba(128,128,128,0.1)', color: 'var(--text-secondary)' };
const cmdText = cmd.label ? t(cmd.label) : (cmdCode.toUpperCase() || '-');
const ageSec = Math.max(0, Math.floor((Date.now() - new Date(log.ts).getTime()) / 1000));
const doors = doorSummary(log);
const remote = remoteSummary(log);
const trim = escapeHtml(log.raw_trim || '-');
const raw = escapeHtml(log.raw_full || '');
return `
<tr>
<td class="font-mono text-muted">${escapeHtml(String(log.ts || '').substring(11))}</td>
<td><div class="cmd-pill" style="background:${cmd.bg}; color:${cmd.color}"><i class="fas ${cmd.icon}"></i> ${cmdText}</div></td>
<td class="font-mono ${parseInt(log.engine) === 1 ? 'text-warning' : 'text-secondary'}">${parseInt(log.engine) === 1 ? t('on') : t('off')}</td>
<td class="font-mono">${formatVoltage(log.battery_voltage)}</td>
<td class="font-mono text-secondary">${doors}</td>
<td class="font-mono text-secondary">${remote}</td>
<td class="font-mono ${parseInt(log.hazard) === 1 ? 'text-warning' : 'text-muted'}">${parseInt(log.hazard) === 1 ? t('on') : '-'}</td>
<td class="font-mono text-muted">${formatAge(ageSec)}</td>
<td><div class="detail-text trim-link" data-raw="${raw}" title="${t('rawView')}">${trim}</div></td>
</tr>`;
}).join('');
if (tbody.innerHTML !== html) tbody.innerHTML = html;
}
function updateChart(data) {
if (!batteryChart) return;
batteryChart.data.labels = data.map(row => String(row.ts || '').substring(11, 16));
batteryChart.data.datasets[0].data = data.map(row => parseFloat(row.battery_voltage || 0));
batteryChart.update();
}
function batteryLabel(v, engineOn) {
if (!v) return '-';
if (engineOn && v >= 13.5) return t('charging');
if (v > 12.7) return t('normal');
if (v >= 12.0) return t('watch');
return t('low');
}
function doorSummary(row) {
const open = [];
if (parseInt(row.door_fl) === 1) open.push('FL');
if (parseInt(row.door_fr) === 1) open.push('FR');
if (parseInt(row.door_rl) === 1) open.push('RL');
if (parseInt(row.door_rr) === 1) open.push('RR');
if (parseInt(row.door_trunk) === 1) open.push('TR');
return open.length ? open.join(',') : '-';
}
function remoteSummary(row) {
if (parseInt(row.remote_start_running) === 1) return t('running') + ' ' + (row.remote_start_remaining || '');
if (parseInt(row.remote_start_preparing) === 1) return t('preparing');
return '-';
}
function formatVoltage(v) {
const n = parseFloat(v || 0);
return n ? n.toFixed(1) + 'V' : '-';
}
function formatBytes(bytes) {
const n = Math.max(0, Number(bytes || 0));
if (n >= 1024 * 1024) return (n / 1024 / 1024).toFixed(2) + 'MB';
if (n >= 1024) return (n / 1024).toFixed(1) + 'KB';
return Math.round(n) + 'B';
}
function formatPreciseMb(bytes) {
const n = Math.max(0, Number(bytes || 0));
return (n / 1024 / 1024).toFixed(3) + 'MB';
}
function formatKrw(value) {
const n = Math.max(0, Number(value || 0));
const billed = Math.floor(n / 10) * 10;
if (currentLang === 'ko') return billed.toLocaleString('ko-KR') + '원';
return '₩' + billed.toLocaleString('en-US');
}
function formatUsageMonth(value) {
const parts = String(value || '').split('-');
if (parts.length !== 2) return value || '-';
const month = Number(parts[1]);
if (!month) return value;
return currentLang === 'ko' ? month + '월' : new Date(2000, month - 1, 1).toLocaleString('en-US', { month: 'short' });
}
function formatAge(sec) {
if (sec < 60) return '+' + formatSecondsShort(sec);
if (sec < 3600) return '+' + Math.floor(sec / 60) + t('minShort');
return '+' + Math.floor(sec / 3600) + t('hourShort');
}
function formatDuration(sec) {
sec = Math.max(0, Number(sec || 0));
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60);
if (d > 0) return d + t('dayShort') + ' ' + h + t('hourShort');
if (h > 0) return h + t('hourShort') + ' ' + m + t('minShort');
if (m > 0) return m + t('minShort') + ' ' + s + t('secShort');
return formatSecondsShort(s);
}
function formatAgoDuration(sec) {
sec = Math.max(0, Number(sec || 0));
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60);
if (d > 0) return d + t('dayShort') + ' ' + h + t('hourShort') + ' ' + m + t('minShort');
if (h > 0) return h + t('hourShort') + ' ' + m + t('minShort');
if (m > 0) return m + t('minShort');
return s + t('secShort');
}
function formatAgeUnits(sec) {
sec = Math.max(0, Number(sec || 0));
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60);
const parts = [];
if (d > 0) parts.push(d + t('dayShort'));
if (h > 0 || parts.length > 0) parts.push(h + t('hourShort'));
if (m > 0 || parts.length > 0) parts.push(m + t('minShort'));
parts.push(s + t('secShort'));
return parts.join(' ');
}
function formatSecondsShort(sec) {
return Math.max(0, Number(sec || 0)) + t('secShort');
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
document.addEventListener('click', e => {
const el = e.target.closest('.trim-link');
if (!el) return;
document.getElementById('rawModalContent').textContent = el.dataset.raw || '(empty)';
new bootstrap.Modal(document.getElementById('rawModal')).show();
});
</script>
<div class="modal fade" id="rawModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content glass-card">
<div class="modal-header">
<h6 class="modal-title fw-bold" data-i18n="rawFullData">RAW FULL DATA</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<pre id="rawModalContent" class="font-mono small mb-0"></pre>
</div>
</div>
</div>
</div>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/car/sw.js', {scope: '/car/'}).catch(() => {});
});
}
</script>
<script src="https://chaegeon.com/log/logger.js"></script>
</body>
</html>