3377 lines
133 KiB
PHP
3377 lines
133 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' => 120064512, 'meter_adjusted_bytes' => 129148416, 'included_bytes' => 104857600, 'coupon_registered_at' => null],
|
||
'2026-07' => ['total_bytes' => 6549504, 'meter_adjusted_bytes' => 7169536, 'included_bytes' => 104857600, 'coupon_registered_at' => null],
|
||
];
|
||
|
||
function car_asset_url(string $path): string
|
||
{
|
||
$relativePath = preg_replace('#^/car/#', '', $path) ?? ltrim($path, '/');
|
||
$fullPath = __DIR__ . '/' . ltrim($relativePath, '/');
|
||
$version = 'dev';
|
||
|
||
if (is_file($fullPath)) {
|
||
$mtime = filemtime($fullPath) ?: time();
|
||
$hash = is_readable($fullPath) ? substr(hash_file('sha256', $fullPath) ?: '', 0, 10) : '';
|
||
$version = $mtime . ($hash !== '' ? '-' . $hash : '');
|
||
}
|
||
|
||
return $path . '?v=' . rawurlencode($version);
|
||
}
|
||
|
||
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,
|
||
]);
|
||
db_ensure_data_usage_schema($pdo);
|
||
|
||
return $pdo;
|
||
}
|
||
|
||
function db_ensure_data_usage_schema(PDO $pdo): void
|
||
{
|
||
static $done = false;
|
||
if ($done) {
|
||
return;
|
||
}
|
||
|
||
$stmt = $pdo->query("SHOW COLUMNS FROM car_data_usage LIKE 'carrier_estimated_bytes'");
|
||
if (!$stmt || !$stmt->fetch()) {
|
||
$pdo->exec("ALTER TABLE car_data_usage ADD COLUMN carrier_estimated_bytes INT UNSIGNED NOT NULL DEFAULT 0 AFTER total_bytes");
|
||
}
|
||
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS car_usage_calibrations (
|
||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
month CHAR(7) NOT NULL,
|
||
anchor_ts DATETIME NOT NULL,
|
||
total_bytes BIGINT UNSIGNED NOT NULL,
|
||
meter_adjusted_bytes BIGINT UNSIGNED NOT NULL,
|
||
included_bytes BIGINT UNSIGNED NOT NULL DEFAULT 104857600,
|
||
source VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||
note VARCHAR(255) NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uniq_month_anchor (month, anchor_ts),
|
||
KEY idx_month_anchor (month, anchor_ts)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||
|
||
$done = true;
|
||
}
|
||
|
||
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');
|
||
|
||
$todayStart = $now->setTime(0, 0, 0);
|
||
$tomorrowStart = $todayStart->modify('+1 day');
|
||
$usage = calibrated_usage_range($pdo, $monthStart, $nextMonthStart);
|
||
$todayUsage = calibrated_usage_range($pdo, $todayStart, $tomorrowStart);
|
||
|
||
$sentBytes = $usage['sent_bytes'];
|
||
$receivedBytes = $usage['received_bytes'];
|
||
$totalBytes = $usage['total_bytes'];
|
||
$adjustedTotalBytes = $usage['adjusted_total_bytes'];
|
||
$carrierAdjustedTotalBytes = $usage['carrier_adjusted_total_bytes'];
|
||
$remainingBytes = max(DATA_MONTHLY_INCLUDED_BYTES - $carrierAdjustedTotalBytes, 0);
|
||
$overBytes = max($carrierAdjustedTotalBytes - 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'), $pdo);
|
||
$billingScaleInfo = billing_meter_scale_info($monthStart->format('Y-m'), $currentBilling, $pdo);
|
||
$billingScale = $billingScaleInfo['scale'];
|
||
$billingCurrentBytes = (int)round($carrierAdjustedTotalBytes * $billingScale);
|
||
$projectedBytes = (int)round($carrierAdjustedTotalBytes / $measuredSeconds * $monthSeconds);
|
||
$billingProjectedBytes = $carrierAdjustedTotalBytes > 0
|
||
? max((int)round($projectedBytes * $billingScale), $billingCurrentBytes)
|
||
: 0;
|
||
$calibration = billing_calibration($currentBilling, $billingScaleInfo);
|
||
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['carrier_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,
|
||
'carrier_adjusted_total_bytes' => $carrierAdjustedTotalBytes,
|
||
'carrier_adjusted_failure_bytes' => $usage['carrier_adjusted_failure_bytes'],
|
||
'billing_sent_bytes' => (int)round($sentBytes * $billingScale),
|
||
'billing_received_bytes' => (int)round($receivedBytes * $billingScale),
|
||
'billing_adjusted_failure_bytes' => (int)round($usage['carrier_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_carrier_adjusted_total_bytes' => $todayUsage['carrier_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_meter_scale_source' => $billingScaleInfo['source'],
|
||
'billing_meter_scale_source_month' => $billingScaleInfo['source_month'],
|
||
'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 usage_success_averages(PDO $pdo, DateTimeInterface $start, DateTimeInterface $end): array
|
||
{
|
||
$successfulTotalBytes = 0;
|
||
$successfulCount = 0;
|
||
$avgByCmd = [];
|
||
|
||
$stmt = $pdo->prepare(""
|
||
. "SELECT cmd, SUM(total_bytes) AS total_bytes, COUNT(*) AS sample_count "
|
||
. "FROM car_data_usage "
|
||
. "WHERE id='car' AND ts >= :start_ts AND ts < :end_ts "
|
||
. "AND tcp_ok = 1 AND total_bytes > 0 "
|
||
. "GROUP BY cmd"
|
||
);
|
||
$stmt->execute([
|
||
':start_ts' => $start->format('Y-m-d H:i:s'),
|
||
':end_ts' => $end->format('Y-m-d H:i:s'),
|
||
]);
|
||
|
||
while ($row = $stmt->fetch()) {
|
||
$cmd = (string)($row['cmd'] ?? '');
|
||
$total = (int)($row['total_bytes'] ?? 0);
|
||
$count = (int)($row['sample_count'] ?? 0);
|
||
if ($count <= 0) {
|
||
continue;
|
||
}
|
||
|
||
$successfulTotalBytes += $total;
|
||
$successfulCount += $count;
|
||
$avgByCmd[$cmd] = (int)round($total / $count);
|
||
}
|
||
|
||
$globalAverage = $successfulCount > 0 ? (int)round($successfulTotalBytes / $successfulCount) : 0;
|
||
|
||
return [
|
||
'avg_by_cmd' => $avgByCmd,
|
||
'global_average' => $globalAverage,
|
||
'success_count' => $successfulCount,
|
||
];
|
||
}
|
||
|
||
function carrier_billing_bytes(int $payloadBytes): int
|
||
{
|
||
$payloadBytes = max(0, $payloadBytes);
|
||
if ($payloadBytes <= 0) {
|
||
return 0;
|
||
}
|
||
|
||
return (int)(ceil($payloadBytes / DATA_BILLING_UNIT_BYTES) * DATA_BILLING_UNIT_BYTES);
|
||
}
|
||
|
||
function calibrated_usage_range(PDO $pdo, DateTimeInterface $start, DateTimeInterface $end): array
|
||
{
|
||
$averages = usage_success_averages($pdo, $start, $end);
|
||
$avgByCmd = $averages['avg_by_cmd'];
|
||
$globalAverage = $averages['global_average'];
|
||
|
||
$sentBytes = 0;
|
||
$receivedBytes = 0;
|
||
$totalBytes = 0;
|
||
$firstUsageTs = null;
|
||
$lastUsageTs = null;
|
||
|
||
$adjustedTotalBytes = 0;
|
||
$adjustedFailureBytes = 0;
|
||
$carrierAdjustedTotalBytes = 0;
|
||
$carrierAdjustedFailureBytes = 0;
|
||
$connectedFailureCount = 0;
|
||
$connectFailureCount = 0;
|
||
$receiveGapExcludedCount = 0;
|
||
$lastSuccessfulTs = null;
|
||
$sampleCount = 0;
|
||
|
||
$stmt = null;
|
||
$previousBuffered = $pdo->getAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY);
|
||
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
|
||
|
||
try {
|
||
$stmt = $pdo->prepare(""
|
||
. "SELECT ts, cmd, sent_bytes, received_bytes, total_bytes, carrier_estimated_bytes, tcp_ok "
|
||
. "FROM car_data_usage "
|
||
. "WHERE id='car' AND ts >= :start_ts AND ts < :end_ts "
|
||
. "ORDER BY ts ASC"
|
||
);
|
||
$stmt->execute([
|
||
':start_ts' => $start->format('Y-m-d H:i:s'),
|
||
':end_ts' => $end->format('Y-m-d H:i:s'),
|
||
]);
|
||
|
||
while ($row = $stmt->fetch()) {
|
||
$sampleCount++;
|
||
$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;
|
||
$rowTs = strtotime((string)($row['ts'] ?? ''));
|
||
|
||
$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) {
|
||
$adjustedTotalBytes += $total;
|
||
$carrierAdjustedTotalBytes += (int)($row['carrier_estimated_bytes'] ?? 0) > 0
|
||
? (int)$row['carrier_estimated_bytes']
|
||
: carrier_billing_bytes($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;
|
||
$carrierEstimatedBytes = carrier_billing_bytes($estimatedBytes);
|
||
$carrierAdjustedTotalBytes += $carrierEstimatedBytes;
|
||
$carrierAdjustedFailureBytes += $carrierEstimatedBytes;
|
||
}
|
||
} finally {
|
||
if ($stmt instanceof PDOStatement) {
|
||
$stmt->closeCursor();
|
||
}
|
||
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, $previousBuffered);
|
||
}
|
||
|
||
return [
|
||
'sent_bytes' => $sentBytes,
|
||
'received_bytes' => $receivedBytes,
|
||
'total_bytes' => $totalBytes,
|
||
'adjusted_total_bytes' => $adjustedTotalBytes,
|
||
'adjusted_failure_bytes' => $adjustedFailureBytes,
|
||
'carrier_adjusted_total_bytes' => $carrierAdjustedTotalBytes,
|
||
'carrier_adjusted_failure_bytes' => $carrierAdjustedFailureBytes,
|
||
'connected_failure_count' => $connectedFailureCount,
|
||
'connect_failure_count' => $connectFailureCount,
|
||
'receive_gap_excluded_count' => $receiveGapExcludedCount,
|
||
'sample_count' => $sampleCount,
|
||
'success_count' => $averages['success_count'],
|
||
'success_average_bytes' => $globalAverage,
|
||
'first_usage_ts' => $firstUsageTs,
|
||
'last_usage_ts' => $lastUsageTs,
|
||
];
|
||
}
|
||
|
||
function billing_usage_row(array $row): array
|
||
{
|
||
$totalBytes = (int)($row['total_bytes'] ?? 0);
|
||
$includedBytes = (int)($row['included_bytes'] ?? DATA_MONTHLY_INCLUDED_BYTES);
|
||
|
||
return [
|
||
'month' => (string)($row['month'] ?? ''),
|
||
'anchor_ts' => $row['anchor_ts'] ?? null,
|
||
'total_bytes' => $totalBytes,
|
||
'total_mb' => round(bytes_to_mb($totalBytes), 6),
|
||
'meter_adjusted_bytes' => (int)($row['meter_adjusted_bytes'] ?? 0),
|
||
'included_bytes' => $includedBytes,
|
||
'included_mb' => round(bytes_to_mb($includedBytes), 6),
|
||
'coupon_registered_at' => $row['coupon_registered_at'] ?? null,
|
||
'source' => (string)($row['source'] ?? 'manual'),
|
||
'note' => $row['note'] ?? null,
|
||
'created_at' => $row['created_at'] ?? null,
|
||
];
|
||
}
|
||
|
||
function current_billing_usage(string $month, ?PDO $pdo = null): ?array
|
||
{
|
||
if ($pdo instanceof PDO) {
|
||
try {
|
||
$stmt = $pdo->prepare("SELECT month, anchor_ts, total_bytes, meter_adjusted_bytes, included_bytes, source, note, created_at
|
||
FROM car_usage_calibrations
|
||
WHERE month = ?
|
||
ORDER BY anchor_ts DESC, id DESC
|
||
LIMIT 1");
|
||
$stmt->execute([$month]);
|
||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||
if (is_array($row)) {
|
||
return billing_usage_row($row);
|
||
}
|
||
} catch (Throwable $e) {
|
||
// 테이블 생성 전이거나 DB 권한 문제가 있으면 아래 상수 기준점을 사용한다.
|
||
}
|
||
}
|
||
|
||
if (!isset(DATA_CURRENT_BILLING_USAGE_BYTES[$month])) {
|
||
return null;
|
||
}
|
||
|
||
$row = DATA_CURRENT_BILLING_USAGE_BYTES[$month];
|
||
return billing_usage_row([
|
||
'month' => $month,
|
||
'anchor_ts' => $row['anchor_ts'] ?? null,
|
||
'total_bytes' => (int)($row['total_bytes'] ?? 0),
|
||
'meter_adjusted_bytes' => (int)($row['meter_adjusted_bytes'] ?? 0),
|
||
'included_bytes' => (int)($row['included_bytes'] ?? DATA_MONTHLY_INCLUDED_BYTES),
|
||
'coupon_registered_at' => $row['coupon_registered_at'] ?? null,
|
||
'source' => 'constant',
|
||
'note' => '코드 기준점',
|
||
'created_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 previous_billing_usage(string $month, ?PDO $pdo = null): ?array
|
||
{
|
||
$dbRow = null;
|
||
if ($pdo instanceof PDO) {
|
||
try {
|
||
$stmt = $pdo->prepare("SELECT month, anchor_ts, total_bytes, meter_adjusted_bytes, included_bytes, source, note, created_at
|
||
FROM car_usage_calibrations
|
||
WHERE month < ?
|
||
ORDER BY month DESC, anchor_ts DESC, id DESC
|
||
LIMIT 1");
|
||
$stmt->execute([$month]);
|
||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||
if (is_array($row)) {
|
||
$dbRow = billing_usage_row($row);
|
||
}
|
||
} catch (Throwable $e) {
|
||
$dbRow = null;
|
||
}
|
||
}
|
||
|
||
$constantMonth = null;
|
||
foreach (DATA_CURRENT_BILLING_USAGE_BYTES as $billingMonth => $row) {
|
||
if ($billingMonth >= $month || ($row['meter_adjusted_bytes'] ?? 0) <= 0) {
|
||
continue;
|
||
}
|
||
if ($constantMonth === null || $billingMonth > $constantMonth) {
|
||
$constantMonth = $billingMonth;
|
||
}
|
||
}
|
||
$constantRow = $constantMonth ? current_billing_usage($constantMonth, null) : null;
|
||
|
||
if ($dbRow && (!$constantRow || strcmp((string)$dbRow['month'], (string)$constantRow['month']) >= 0)) {
|
||
return $dbRow;
|
||
}
|
||
|
||
return $constantRow;
|
||
}
|
||
|
||
function billing_meter_scale_info(string $month, ?array $currentBilling, ?PDO $pdo = null): array
|
||
{
|
||
$currentScale = billing_meter_scale($currentBilling);
|
||
if ($currentBilling && $currentScale > 0) {
|
||
return [
|
||
'scale' => $currentScale,
|
||
'source' => 'current_month_anchor',
|
||
'source_month' => $month,
|
||
'source_billing' => $currentBilling,
|
||
];
|
||
}
|
||
|
||
$fallbackBilling = previous_billing_usage($month, $pdo);
|
||
|
||
$fallbackScale = billing_meter_scale($fallbackBilling);
|
||
if ($fallbackBilling && $fallbackScale > 0) {
|
||
return [
|
||
'scale' => $fallbackScale,
|
||
'source' => 'previous_month_anchor',
|
||
'source_month' => $fallbackBilling['month'] ?? null,
|
||
'source_billing' => $fallbackBilling,
|
||
];
|
||
}
|
||
|
||
return [
|
||
'scale' => 1.0,
|
||
'source' => 'meter_adjusted_usage',
|
||
'source_month' => null,
|
||
'source_billing' => null,
|
||
];
|
||
}
|
||
|
||
function billing_calibration(?array $currentBilling, array $billingScaleInfo): array
|
||
{
|
||
$estimatedCurrentBytes = $currentBilling ? (int)$currentBilling['total_bytes'] : 0;
|
||
$billingScale = (float)($billingScaleInfo['scale'] ?? 1.0);
|
||
$source = $billingScaleInfo['source'] ?? 'meter_adjusted_usage';
|
||
$sourceBilling = $billingScaleInfo['source_billing'] ?? null;
|
||
$note = match ($source) {
|
||
'current_month_anchor' => 'T world 현재월 사용량으로 0.5KB 올림 TCP 세션 누적값의 보정 배율을 역산합니다. TCP payload 계측값도 별도로 유지합니다.',
|
||
'previous_month_anchor' => '현재월 T world 기준점이 아직 없어서 가장 최근 월 보정 배율을 임시로 사용합니다. 현재월 기준점이 기록되면 그 배율로 다시 계산합니다.',
|
||
default => '통신사형 사용량은 과금 대상 TCP 세션을 0.5KB 단위로 올림 처리합니다. 순수 연결 실패와 긴 수신 공백은 제외합니다.',
|
||
};
|
||
|
||
return [
|
||
'mode' => $source,
|
||
'note' => $note,
|
||
'current_billing' => $currentBilling,
|
||
'scale_source_billing' => $sourceBilling,
|
||
'billing_meter_scale_source' => $source,
|
||
'billing_meter_scale_source_month' => $billingScaleInfo['source_month'] ?? null,
|
||
'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 car_monitor_is_admin(): bool
|
||
{
|
||
$session = car_monitor_launcher_session();
|
||
return !empty($session['admin']);
|
||
}
|
||
|
||
function car_monitor_require_admin(): void
|
||
{
|
||
if (car_monitor_is_admin()) {
|
||
return;
|
||
}
|
||
|
||
json_response([
|
||
'status' => 'forbidden',
|
||
'message' => '관리자 권한이 필요합니다.',
|
||
], 403);
|
||
}
|
||
|
||
function request_payload(): array
|
||
{
|
||
$raw = file_get_contents('php://input');
|
||
if (is_string($raw) && trim($raw) !== '') {
|
||
$json = json_decode($raw, true);
|
||
if (is_array($json)) {
|
||
return $json;
|
||
}
|
||
}
|
||
|
||
return $_POST;
|
||
}
|
||
|
||
function parse_usage_bytes(mixed $value, string $unit): int
|
||
{
|
||
$normalized = trim(str_replace(',', '', (string)$value));
|
||
if ($normalized === '' || !is_numeric($normalized)) {
|
||
throw new InvalidArgumentException('T world 사용량을 숫자로 입력해야 합니다.');
|
||
}
|
||
|
||
$amount = (float)$normalized;
|
||
if ($amount < 0) {
|
||
throw new InvalidArgumentException('T world 사용량은 0 이상이어야 합니다.');
|
||
}
|
||
|
||
return match (strtolower(trim($unit))) {
|
||
'b', 'byte', 'bytes' => (int)round($amount),
|
||
'kb', 'kib' => (int)round($amount * 1024),
|
||
'mb', 'mib' => (int)round($amount * 1024 * 1024),
|
||
default => throw new InvalidArgumentException('사용량 단위는 B, KB, MB 중 하나여야 합니다.'),
|
||
};
|
||
}
|
||
|
||
function parse_anchor_datetime(?string $value): DateTimeImmutable
|
||
{
|
||
$timezone = new DateTimeZone(date_default_timezone_get());
|
||
$raw = trim((string)$value);
|
||
if ($raw === '') {
|
||
return new DateTimeImmutable('now', $timezone);
|
||
}
|
||
|
||
$normalized = str_replace('T', ' ', $raw);
|
||
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $normalized)) {
|
||
$normalized .= ':00';
|
||
}
|
||
|
||
$dt = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $normalized, $timezone);
|
||
if (!$dt) {
|
||
throw new InvalidArgumentException('기준 시각 형식이 올바르지 않습니다.');
|
||
}
|
||
|
||
return $dt;
|
||
}
|
||
|
||
function save_usage_calibration(PDO $pdo, array $payload): array
|
||
{
|
||
$anchor = parse_anchor_datetime($payload['anchor_ts'] ?? null);
|
||
$monthStart = $anchor->modify('first day of this month')->setTime(0, 0, 0);
|
||
$nextMonthStart = $monthStart->modify('first day of next month');
|
||
if ($anchor < $monthStart || $anchor >= $nextMonthStart) {
|
||
throw new InvalidArgumentException('기준 시각이 현재 월 범위를 벗어났습니다.');
|
||
}
|
||
|
||
$carrierBytes = parse_usage_bytes($payload['total_usage'] ?? '', (string)($payload['unit'] ?? 'KB'));
|
||
$rangeUsage = calibrated_usage_range($pdo, $monthStart, $anchor);
|
||
$meterBytes = (int)$rangeUsage['carrier_adjusted_total_bytes'];
|
||
if ($meterBytes <= 0) {
|
||
throw new RuntimeException('기준 시각까지의 로컬 통신사형 누적값이 없습니다.');
|
||
}
|
||
|
||
$month = $monthStart->format('Y-m');
|
||
$anchorText = $anchor->format('Y-m-d H:i:s');
|
||
$note = trim((string)($payload['note'] ?? ''));
|
||
if (function_exists('mb_strlen') && mb_strlen($note) > 255) {
|
||
$note = mb_substr($note, 0, 255);
|
||
} elseif (strlen($note) > 255) {
|
||
$note = substr($note, 0, 255);
|
||
}
|
||
|
||
$stmt = $pdo->prepare("INSERT INTO car_usage_calibrations
|
||
(month, anchor_ts, total_bytes, meter_adjusted_bytes, included_bytes, source, note)
|
||
VALUES (?, ?, ?, ?, ?, 'manual', ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
total_bytes = VALUES(total_bytes),
|
||
meter_adjusted_bytes = VALUES(meter_adjusted_bytes),
|
||
included_bytes = VALUES(included_bytes),
|
||
source = VALUES(source),
|
||
note = VALUES(note),
|
||
created_at = CURRENT_TIMESTAMP");
|
||
$stmt->execute([
|
||
$month,
|
||
$anchorText,
|
||
$carrierBytes,
|
||
$meterBytes,
|
||
DATA_MONTHLY_INCLUDED_BYTES,
|
||
$note === '' ? null : $note,
|
||
]);
|
||
|
||
return [
|
||
'month' => $month,
|
||
'anchor_ts' => $anchorText,
|
||
'total_bytes' => $carrierBytes,
|
||
'meter_adjusted_bytes' => $meterBytes,
|
||
'scale' => round($carrierBytes / $meterBytes, 6),
|
||
'note' => $note === '' ? null : $note,
|
||
];
|
||
}
|
||
|
||
function usage_calibration_rows(PDO $pdo): array
|
||
{
|
||
$stmt = $pdo->query("SELECT month, anchor_ts, total_bytes, meter_adjusted_bytes, included_bytes, source, note, created_at
|
||
FROM car_usage_calibrations
|
||
ORDER BY month DESC, anchor_ts DESC, id DESC
|
||
LIMIT 12");
|
||
$rows = [];
|
||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||
$billing = billing_usage_row($row);
|
||
$meter = max(1, (int)$billing['meter_adjusted_bytes']);
|
||
$billing['scale'] = round((int)$billing['total_bytes'] / $meter, 6);
|
||
$rows[] = $billing;
|
||
}
|
||
|
||
return $rows;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
function car_monitor_launcher_session(): array
|
||
{
|
||
static $session = null;
|
||
|
||
if (is_array($session)) {
|
||
return $session;
|
||
}
|
||
|
||
$commonAuth = '/mnt/synology-web/custom/common/auth.php';
|
||
if (is_readable($commonAuth)) {
|
||
require_once $commonAuth;
|
||
|
||
if (function_exists('custom_has_auth_cookie') && custom_has_auth_cookie()) {
|
||
$isAdmin = function_exists('custom_auth_cookie_is_admin') && custom_auth_cookie_is_admin();
|
||
$session = ['authenticated' => true, 'admin' => $isAdmin];
|
||
return $session;
|
||
}
|
||
|
||
if (function_exists('custom_is_site_admin') && custom_is_site_admin()) {
|
||
if (function_exists('custom_issue_auth_cookie')) {
|
||
custom_issue_auth_cookie(true);
|
||
}
|
||
$session = ['authenticated' => true, 'admin' => true];
|
||
return $session;
|
||
}
|
||
}
|
||
|
||
$cookieHeader = trim((string)($_SERVER['HTTP_COOKIE'] ?? ''));
|
||
if ($cookieHeader === '') {
|
||
$session = ['authenticated' => false, 'admin' => false];
|
||
return $session;
|
||
}
|
||
|
||
$url = 'https://chaegeon.com/custom/launcher/api.php?session=1';
|
||
$body = false;
|
||
|
||
if (function_exists('curl_init')) {
|
||
$ch = curl_init($url);
|
||
if ($ch !== false) {
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_CONNECTTIMEOUT => 2,
|
||
CURLOPT_TIMEOUT => 4,
|
||
CURLOPT_HTTPHEADER => ['Cookie: ' . $cookieHeader],
|
||
CURLOPT_USERAGENT => 'car-monitor-auth/1.0',
|
||
]);
|
||
$body = curl_exec($ch);
|
||
curl_close($ch);
|
||
}
|
||
}
|
||
|
||
if ($body === false) {
|
||
$context = stream_context_create([
|
||
'http' => [
|
||
'method' => 'GET',
|
||
'timeout' => 4,
|
||
'header' => "Cookie: {$cookieHeader}\r\nUser-Agent: car-monitor-auth/1.0\r\n",
|
||
],
|
||
]);
|
||
$body = @file_get_contents($url, false, $context);
|
||
}
|
||
|
||
$data = is_string($body) ? json_decode($body, true) : null;
|
||
$session = is_array($data) ? $data : ['authenticated' => false, 'admin' => false];
|
||
|
||
return $session;
|
||
}
|
||
|
||
function car_monitor_has_access(): bool
|
||
{
|
||
$session = car_monitor_launcher_session();
|
||
return !empty($session['authenticated']);
|
||
}
|
||
|
||
function car_monitor_require_access(bool $json = false): void
|
||
{
|
||
if (car_monitor_has_access()) {
|
||
return;
|
||
}
|
||
|
||
if ($json) {
|
||
json_response([
|
||
'status' => 'unauthorized',
|
||
'message' => '런처 인증이 필요합니다.',
|
||
], 401);
|
||
}
|
||
|
||
http_response_code(401);
|
||
header('Content-Type: text/html; charset=utf-8');
|
||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>차량 모니터 인증</title>
|
||
<link rel="stylesheet" href="https://chaegeon.com/custom/common/css/launcher.css">
|
||
</head>
|
||
<body>
|
||
<main class="car-auth-page">
|
||
<div id="findMyShortcut"></div>
|
||
</main>
|
||
<script src="https://chaegeon.com/custom/common/js/auth-pin.js?v=<?php echo (int)(@filemtime('/mnt/synology-web/custom/common/js/auth-pin.js') ?: time()); ?>"></script>
|
||
<script>
|
||
window.addEventListener('DOMContentLoaded', function () {
|
||
window.CustomAuthPin && window.CustomAuthPin.render({
|
||
mount: '#findMyShortcut',
|
||
apiUrl: '/car/monitor.php?mode=auth-login',
|
||
title: '비밀번호',
|
||
placeholder: '비밀번호',
|
||
backLabel: '채건닷컴',
|
||
backHref: 'https://chaegeon.com/',
|
||
onSuccess: function () { location.reload(); }
|
||
});
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
<?php
|
||
exit;
|
||
}
|
||
|
||
function car_monitor_issue_pwa_cookie_if_requested(): void
|
||
{
|
||
if (($_GET['pwa'] ?? '') !== '1') {
|
||
return;
|
||
}
|
||
|
||
$fetchDest = strtolower((string)($_SERVER['HTTP_SEC_FETCH_DEST'] ?? ''));
|
||
if ($fetchDest !== '' && $fetchDest !== 'document') {
|
||
return;
|
||
}
|
||
|
||
$commonAuth = '/mnt/synology-web/custom/common/auth.php';
|
||
if (!is_readable($commonAuth)) {
|
||
return;
|
||
}
|
||
|
||
require_once $commonAuth;
|
||
if (function_exists('custom_issue_auth_cookie')) {
|
||
if (function_exists('custom_has_auth_cookie') && custom_has_auth_cookie()) {
|
||
header('Location: /car/monitor.php', true, 302);
|
||
exit;
|
||
}
|
||
$isAdmin = function_exists('custom_is_site_admin') && custom_is_site_admin();
|
||
custom_issue_auth_cookie($isAdmin);
|
||
}
|
||
|
||
header('Location: /car/monitor.php', true, 302);
|
||
exit;
|
||
}
|
||
|
||
function car_monitor_auth_api(): void
|
||
{
|
||
$commonAuth = '/mnt/synology-web/custom/common/auth.php';
|
||
if (!is_readable($commonAuth)) {
|
||
json_response(['result' => 'fail', 'message' => 'auth_module_missing'], 500);
|
||
}
|
||
require_once $commonAuth;
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['failMessages'])) {
|
||
json_response(['failMessages' => ['비밀번호가 틀립니다.']]);
|
||
}
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
json_response(['error' => 'method_not_allowed'], 405);
|
||
}
|
||
|
||
$data = request_payload();
|
||
$password = (string)($data['password'] ?? '');
|
||
if (preg_match('/^\d{4}$/', $password) !== 1) {
|
||
json_response(['result' => 'fail', 'message' => 'password_digits_only'], 401);
|
||
}
|
||
|
||
if (function_exists('custom_verify_password') && custom_verify_password($password)) {
|
||
custom_issue_auth_cookie(false);
|
||
json_response(['result' => 'ok']);
|
||
}
|
||
|
||
json_response(['result' => 'fail'], 401);
|
||
}
|
||
|
||
if (isset($_GET['mode']) && $_GET['mode'] === 'auth-login') {
|
||
car_monitor_auth_api();
|
||
}
|
||
|
||
car_monitor_issue_pwa_cookie_if_requested();
|
||
|
||
if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') {
|
||
car_monitor_require_access(true);
|
||
|
||
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();
|
||
$serverNow = time();
|
||
foreach ($logs as &$logRow) {
|
||
$rowTs = strtotime((string)($logRow['ts'] ?? ''));
|
||
$logRow['age_seconds'] = $rowTs !== false
|
||
? max(0, $serverNow - $rowTs)
|
||
: 0;
|
||
}
|
||
unset($logRow);
|
||
|
||
$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,
|
||
'server_time' => $serverNow,
|
||
'state_duration' => current_engine_state_duration($pdo, $latest),
|
||
'engine_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') {
|
||
car_monitor_require_access(true);
|
||
|
||
try {
|
||
json_response([
|
||
'status' => 'success',
|
||
'usage' => monthly_data_usage(car_db()),
|
||
]);
|
||
} catch (Throwable $e) {
|
||
json_response([
|
||
'status' => 'error',
|
||
'message' => '데이터 사용량 조회 실패',
|
||
], 500);
|
||
}
|
||
}
|
||
|
||
if (isset($_GET['mode']) && $_GET['mode'] === 'usage-calibration') {
|
||
car_monitor_require_access(true);
|
||
car_monitor_require_admin();
|
||
|
||
try {
|
||
$pdo = car_db();
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$saved = save_usage_calibration($pdo, request_payload());
|
||
json_response([
|
||
'status' => 'success',
|
||
'saved' => $saved,
|
||
'usage' => monthly_data_usage($pdo),
|
||
'calibrations' => usage_calibration_rows($pdo),
|
||
]);
|
||
}
|
||
|
||
json_response([
|
||
'status' => 'success',
|
||
'usage' => monthly_data_usage($pdo),
|
||
'calibrations' => usage_calibration_rows($pdo),
|
||
'server_time' => time(),
|
||
]);
|
||
} catch (Throwable $e) {
|
||
json_response([
|
||
'status' => 'error',
|
||
'message' => $e->getMessage(),
|
||
], 400);
|
||
}
|
||
}
|
||
|
||
car_monitor_require_access(false);
|
||
$carMonitorSession = car_monitor_launcher_session();
|
||
?>
|
||
<!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="<?= htmlspecialchars(car_asset_url('/car/assets/favicon.svg'), ENT_QUOTES, 'UTF-8') ?>" type="image/svg+xml">
|
||
<link rel="shortcut icon" href="<?= htmlspecialchars(car_asset_url('/car/favicon.ico'), ENT_QUOTES, 'UTF-8') ?>">
|
||
<link rel="icon" href="<?= htmlspecialchars(car_asset_url('/car/assets/icon-32.png'), ENT_QUOTES, 'UTF-8') ?>" type="image/png" sizes="32x32">
|
||
<link rel="icon" href="<?= htmlspecialchars(car_asset_url('/car/assets/icon-192.png'), ENT_QUOTES, 'UTF-8') ?>" type="image/png" sizes="192x192">
|
||
<link rel="apple-touch-icon" href="<?= htmlspecialchars(car_asset_url('/car/assets/apple-touch-icon.png'), ENT_QUOTES, 'UTF-8') ?>">
|
||
<link rel="manifest" href="<?= htmlspecialchars(car_asset_url('/car/assets/site.webmanifest'), ENT_QUOTES, 'UTF-8') ?>">
|
||
<script src="<?= htmlspecialchars(car_asset_url('/car/assets/asset-reload.js'), ENT_QUOTES, 'UTF-8') ?>" data-service="car" defer></script>
|
||
<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 */
|
||
}
|
||
.glass-card > * {
|
||
min-width: 0;
|
||
}
|
||
|
||
/* Headers & Typography */
|
||
.dashboard-header {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: 1.5rem 0.5rem; margin-bottom: 0.5rem;
|
||
}
|
||
.blog-back-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
margin-bottom: 6px;
|
||
color: var(--accent-blue);
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
text-decoration: none;
|
||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.55);
|
||
max-width: min(44vw, 260px);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.blog-back-link:hover,
|
||
.blog-back-link:focus {
|
||
color: var(--accent-blue);
|
||
text-decoration: none;
|
||
}
|
||
.blog-back-link .backArrow {
|
||
font-size: 22px;
|
||
line-height: 1;
|
||
transform: translateY(-1px);
|
||
}
|
||
.app-icon-main {
|
||
width: 75px;
|
||
height: 75px;
|
||
font-size: 1.45rem;
|
||
flex: 0 0 75px;
|
||
}
|
||
.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;
|
||
transform: var(--door-transform, none);
|
||
transform-origin: center;
|
||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||
}
|
||
.door-line.open { opacity: 1; transform: var(--door-transform, none) 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 { --door-transform: translateX(-50%); bottom: 28px; left: 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-scroll {
|
||
max-height: 400px;
|
||
overflow: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
scrollbar-gutter: stable;
|
||
}
|
||
.table-container { width: 100%; min-width: 720px; 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;
|
||
}
|
||
.duration-line {
|
||
display: block;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.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-icon-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-icon-toggle:hover { background: var(--bg-body); transform: scale(1.05); }
|
||
.btn-icon-toggle.active {
|
||
background: var(--box-bg-green);
|
||
border-color: rgba(34, 197, 94, 0.45);
|
||
color: var(--accent-green);
|
||
}
|
||
.admin-settings-btn.d-none {
|
||
display: none !important;
|
||
}
|
||
.custom-modal-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 1080;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 18px;
|
||
background: rgba(15, 23, 42, 0.36);
|
||
backdrop-filter: blur(8px);
|
||
-webkit-backdrop-filter: blur(8px);
|
||
}
|
||
.custom-modal-overlay[hidden] {
|
||
display: none !important;
|
||
}
|
||
.custom-modal-panel {
|
||
width: min(560px, 100%);
|
||
max-height: min(760px, calc(100vh - 36px));
|
||
overflow: auto;
|
||
border-radius: 20px;
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--card-border);
|
||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.28);
|
||
color: var(--text-primary);
|
||
}
|
||
.custom-modal-header,
|
||
.custom-modal-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 18px 20px;
|
||
}
|
||
.custom-modal-header {
|
||
border-bottom: 1px solid var(--card-border);
|
||
}
|
||
.custom-modal-footer {
|
||
border-top: 1px solid var(--card-border);
|
||
justify-content: flex-end;
|
||
}
|
||
.custom-modal-body {
|
||
padding: 18px 20px 20px;
|
||
}
|
||
.calibration-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 120px;
|
||
gap: 12px;
|
||
}
|
||
.calibration-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
margin-bottom: 14px;
|
||
}
|
||
.calibration-field label {
|
||
font-size: 0.78rem;
|
||
font-weight: 700;
|
||
color: var(--text-secondary);
|
||
}
|
||
.calibration-field input,
|
||
.calibration-field select {
|
||
width: 100%;
|
||
border: 1px solid var(--card-border);
|
||
border-radius: 12px;
|
||
background: rgba(255, 255, 255, 0.78);
|
||
color: #0f172a;
|
||
padding: 10px 12px;
|
||
font: inherit;
|
||
outline: none;
|
||
}
|
||
[data-bs-theme="dark"] .calibration-field input,
|
||
[data-bs-theme="dark"] .calibration-field select {
|
||
background: rgba(15, 23, 42, 0.86);
|
||
color: var(--text-primary);
|
||
}
|
||
.calibration-summary {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 8px;
|
||
margin: 14px 0;
|
||
}
|
||
.calibration-summary-item {
|
||
border-radius: 12px;
|
||
background: rgba(148, 163, 184, 0.12);
|
||
padding: 10px 12px;
|
||
}
|
||
.calibration-history {
|
||
margin-top: 14px;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
border: 1px solid var(--card-border);
|
||
}
|
||
.calibration-history-row {
|
||
display: grid;
|
||
grid-template-columns: 92px 1fr 78px;
|
||
gap: 8px;
|
||
padding: 9px 10px;
|
||
font-size: 0.78rem;
|
||
border-bottom: 1px solid var(--card-border);
|
||
}
|
||
.calibration-history-row:last-child {
|
||
border-bottom: 0;
|
||
}
|
||
.calibration-message {
|
||
min-height: 22px;
|
||
font-size: 0.82rem;
|
||
}
|
||
@media (max-width: 575px) {
|
||
.custom-modal-overlay {
|
||
align-items: stretch;
|
||
padding: 10px;
|
||
}
|
||
.custom-modal-panel {
|
||
max-height: calc(100vh - 20px);
|
||
}
|
||
.calibration-grid,
|
||
.calibration-summary {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.calibration-history-row {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
@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;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
-webkit-overflow-scrolling: touch;
|
||
scrollbar-gutter: stable;
|
||
}
|
||
.usage-meter {
|
||
height: 11px;
|
||
border-radius: 999px;
|
||
background: rgba(148, 163, 184, 0.22);
|
||
overflow: hidden;
|
||
margin-bottom: 0.9rem;
|
||
min-width: 660px;
|
||
}
|
||
.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: flex;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
min-width: 660px;
|
||
align-items: flex-start;
|
||
}
|
||
.usage-metric {
|
||
flex: 0 0 auto;
|
||
text-align: center;
|
||
}
|
||
.usage-metric:first-child {
|
||
text-align: left;
|
||
}
|
||
.usage-metric:last-child {
|
||
text-align: right;
|
||
}
|
||
.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 {
|
||
gap: 0.55rem;
|
||
min-width: 0;
|
||
}
|
||
.usage-meter {
|
||
min-width: 0;
|
||
}
|
||
}
|
||
@media (max-width: 575.98px) {
|
||
.container {
|
||
max-width: 100%;
|
||
padding-left: 0.9rem;
|
||
padding-right: 0.9rem;
|
||
}
|
||
.dashboard-header {
|
||
gap: 0.8rem;
|
||
align-items: flex-start;
|
||
}
|
||
.dashboard-header > .d-flex:last-child {
|
||
gap: 0.45rem !important;
|
||
flex: 0 0 auto;
|
||
}
|
||
.btn-icon-toggle {
|
||
width: 36px;
|
||
height: 36px;
|
||
}
|
||
.glass-card.h-100.p-4 {
|
||
padding: 1rem !important;
|
||
}
|
||
.value-text {
|
||
font-size: 1.55rem;
|
||
}
|
||
.icon-box {
|
||
width: 42px;
|
||
height: 42px;
|
||
border-radius: 13px;
|
||
margin-bottom: 0.85rem;
|
||
}
|
||
.usage-card {
|
||
padding: 1rem;
|
||
}
|
||
.usage-head {
|
||
gap: 0.75rem;
|
||
}
|
||
.usage-stat .d-flex {
|
||
flex-wrap: nowrap !important;
|
||
}
|
||
.usage-main {
|
||
padding-bottom: 0.25rem;
|
||
overflow-x: visible;
|
||
overflow-y: visible;
|
||
}
|
||
.usage-metrics {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 0.75rem 0.9rem;
|
||
min-width: 0;
|
||
}
|
||
.usage-meter {
|
||
min-width: 0;
|
||
}
|
||
.usage-metric {
|
||
min-width: 0;
|
||
text-align: center;
|
||
}
|
||
.usage-metric:nth-child(3n + 1) {
|
||
text-align: left;
|
||
}
|
||
.usage-metric:nth-child(3n) {
|
||
text-align: right;
|
||
}
|
||
.usage-metric .label-text { font-size: 0.64rem; }
|
||
.usage-metric .font-mono { font-size: 0.88rem; }
|
||
.usage-detail-line {
|
||
white-space: normal;
|
||
}
|
||
.table-scroll {
|
||
max-height: 52vh;
|
||
}
|
||
.table-container {
|
||
min-width: 660px;
|
||
}
|
||
.table-container th {
|
||
padding-left: 8px;
|
||
padding-right: 8px;
|
||
}
|
||
.table-container td {
|
||
padding: 9px 8px;
|
||
font-size: 0.82rem;
|
||
}
|
||
.cmd-pill {
|
||
min-width: 78px;
|
||
padding-left: 8px;
|
||
padding-right: 8px;
|
||
}
|
||
.detail-text {
|
||
max-width: 140px;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container py-3">
|
||
<div class="dashboard-header">
|
||
<div class="d-flex align-items-center gap-3">
|
||
<div class="app-icon-main bg-primary bg-opacity-10 text-primary rounded-circle d-flex align-items-center justify-content-center border border-primary border-opacity-25">
|
||
<i class="fas fa-car"></i>
|
||
</div>
|
||
<div>
|
||
<a href="https://chaegeon.com/" id="blogBackLink" class="blog-back-link"><span class="backArrow">‹</span> 채건닷컴</a>
|
||
<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-icon-toggle shadow-sm" id="wakelock-btn" onclick="toggleWakeLock()" title="WakeLock">
|
||
<i class="fas fa-mobile-screen-button"></i>
|
||
</button>
|
||
<button class="btn-icon-toggle shadow-sm" id="lang-btn" onclick="toggleLanguage()" title="Language">
|
||
<i class="fas fa-globe"></i>
|
||
</button>
|
||
<button class="btn-icon-toggle shadow-sm" id="theme-btn" onclick="toggleTheme()">
|
||
<i class="fas fa-moon"></i>
|
||
</button>
|
||
<button class="btn-icon-toggle shadow-sm admin-settings-btn d-none" id="settings-btn" type="button" title="설정">
|
||
<i class="fas fa-gear"></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 duration-line" 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 duration-line">-</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 table-scroll">
|
||
<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}일 남음',
|
||
wakeLockTitle: '화면 켜짐 유지',
|
||
wakeLockUnsupported: '화면 켜짐 유지 미지원',
|
||
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',
|
||
wakeLockTitle: 'Keep screen awake',
|
||
wakeLockUnsupported: 'WakeLock unavailable',
|
||
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 CAR_MONITOR_SESSION = <?php echo json_encode($carMonitorSession, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
|
||
|
||
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;
|
||
let wakeLockSentinel = null;
|
||
let wakeLockWanted = false;
|
||
let latestDashboardData = null;
|
||
let latestDashboardMeta = null;
|
||
let latestUsageData = null;
|
||
let calibrationModalOpen = false;
|
||
let calibrationSaveInFlight = false;
|
||
const FALLBACK_BACK_TARGET = 'https://chaegeon.com/';
|
||
const BACK_HISTORY_KEY = 'chaegeon.backHistory.v1';
|
||
const BACK_LINK_PATH_TITLE = {
|
||
'/': '채건닷컴',
|
||
'/home': '홈',
|
||
'/blog': '블로그',
|
||
};
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
setupBackLink();
|
||
initLanguage();
|
||
initTheme();
|
||
initWakeLock();
|
||
initCalibrationSettings();
|
||
initChart();
|
||
fetchData();
|
||
refreshTimer = setInterval(fetchData, 1000);
|
||
fetchUsage();
|
||
window.addEventListener('resize', debounce(updateResponsiveDurations, 150));
|
||
});
|
||
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'visible' && wakeLockWanted) {
|
||
requestWakeLock();
|
||
}
|
||
});
|
||
|
||
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 cleanPageTitle(title) {
|
||
return String(title || '')
|
||
.replace(/^채건닷컴\s*-\s*/i, '')
|
||
.replace(/\s*-\s*채건닷컴$/i, '')
|
||
.trim();
|
||
}
|
||
|
||
function sameSiteUrl(url) {
|
||
try {
|
||
const parsed = new URL(url, location.href);
|
||
return parsed.hostname === location.hostname || parsed.hostname.endsWith('.chaegeon.com') || parsed.hostname === 'chaegeon.com'
|
||
? parsed
|
||
: null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function titleFromBackUrl(url) {
|
||
const parsed = sameSiteUrl(url);
|
||
if (!parsed) return '';
|
||
return BACK_LINK_PATH_TITLE[parsed.pathname.replace(/\/$/, '') || '/'] || cleanPageTitle(parsed.pathname.split('/').filter(Boolean).pop() || '');
|
||
}
|
||
|
||
function backPageKey(url) {
|
||
const parsed = sameSiteUrl(url);
|
||
if (!parsed) return '';
|
||
const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
|
||
return `${parsed.hostname.replace(/^www\./, '')}${pathname}`;
|
||
}
|
||
|
||
function rememberBackHistory() {
|
||
try {
|
||
const current = new URL(location.href);
|
||
const list = JSON.parse(sessionStorage.getItem(BACK_HISTORY_KEY) || '[]')
|
||
.filter(item => item && item.href && item.key);
|
||
const entry = { href: current.href, key: backPageKey(current.href), time: Date.now() };
|
||
const last = list[list.length - 1];
|
||
const next = last && last.href === entry.href ? list : [...list, entry];
|
||
sessionStorage.setItem(BACK_HISTORY_KEY, JSON.stringify(next.slice(-20)));
|
||
} catch (e) {}
|
||
}
|
||
|
||
function storedBackTarget(currentKey) {
|
||
try {
|
||
const list = JSON.parse(sessionStorage.getItem(BACK_HISTORY_KEY) || '[]');
|
||
for (let i = list.length - 1; i >= 0; i--) {
|
||
const item = list[i];
|
||
if (item?.href && item.key && item.key !== currentKey && sameSiteUrl(item.href)) {
|
||
return item.href;
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
return '';
|
||
}
|
||
|
||
function updateBackLinkLabel(label) {
|
||
const link = document.getElementById('blogBackLink');
|
||
if (!link) return;
|
||
link.innerHTML = `<span class="backArrow">‹</span> ${label}`;
|
||
}
|
||
|
||
async function resolveBackTarget() {
|
||
const ref = sameSiteUrl(document.referrer || '');
|
||
const current = new URL(location.href);
|
||
const currentKey = backPageKey(current.href);
|
||
rememberBackHistory();
|
||
const refIsDifferentPage = ref && backPageKey(ref.href) !== currentKey;
|
||
const target = refIsDifferentPage ? ref.href : (storedBackTarget(currentKey) || FALLBACK_BACK_TARGET);
|
||
const knownLabel = titleFromBackUrl(target);
|
||
let label = knownLabel;
|
||
const targetUrl = sameSiteUrl(target);
|
||
|
||
if (!knownLabel && targetUrl && targetUrl.origin === location.origin) {
|
||
try {
|
||
const res = await fetch(targetUrl.href, { credentials: 'same-origin', cache: 'force-cache' });
|
||
if (res.ok) {
|
||
const html = await res.text();
|
||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||
label = cleanPageTitle(doc.querySelector('title')?.textContent) || label;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
return { href: target, label: label || '블로그' };
|
||
}
|
||
|
||
function setupBackLink() {
|
||
const link = document.getElementById('blogBackLink');
|
||
if (!link) return;
|
||
|
||
link.href = FALLBACK_BACK_TARGET;
|
||
updateBackLinkLabel('채건닷컴');
|
||
|
||
resolveBackTarget().then(({ href, label }) => {
|
||
link.href = href;
|
||
updateBackLinkLabel(label);
|
||
});
|
||
|
||
link.addEventListener('click', (event) => {
|
||
event.preventDefault();
|
||
location.href = link.href || FALLBACK_BACK_TARGET;
|
||
});
|
||
}
|
||
|
||
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'));
|
||
}
|
||
updateWakeLockButton();
|
||
updateResponsiveDurations();
|
||
}
|
||
|
||
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 initWakeLock() {
|
||
wakeLockWanted = localStorage.getItem('wakeLock') === 'on';
|
||
updateWakeLockButton();
|
||
if (wakeLockWanted) {
|
||
requestWakeLock();
|
||
}
|
||
}
|
||
|
||
function wakeLockSupported() {
|
||
return 'wakeLock' in navigator;
|
||
}
|
||
|
||
async function requestWakeLock() {
|
||
if (!wakeLockSupported()) {
|
||
wakeLockWanted = false;
|
||
localStorage.setItem('wakeLock', 'off');
|
||
updateWakeLockButton();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
wakeLockSentinel = await navigator.wakeLock.request('screen');
|
||
wakeLockSentinel.addEventListener('release', () => {
|
||
wakeLockSentinel = null;
|
||
updateWakeLockButton();
|
||
});
|
||
updateWakeLockButton();
|
||
} catch (error) {
|
||
wakeLockSentinel = null;
|
||
updateWakeLockButton();
|
||
}
|
||
}
|
||
|
||
async function releaseWakeLock() {
|
||
if (wakeLockSentinel) {
|
||
try {
|
||
await wakeLockSentinel.release();
|
||
} catch (error) {
|
||
// Browser may already have released it on visibility or power policy changes.
|
||
}
|
||
}
|
||
wakeLockSentinel = null;
|
||
updateWakeLockButton();
|
||
}
|
||
|
||
function toggleWakeLock() {
|
||
wakeLockWanted = !wakeLockWanted;
|
||
localStorage.setItem('wakeLock', wakeLockWanted ? 'on' : 'off');
|
||
if (wakeLockWanted) {
|
||
requestWakeLock();
|
||
} else {
|
||
releaseWakeLock();
|
||
}
|
||
updateWakeLockButton();
|
||
}
|
||
|
||
function updateWakeLockButton() {
|
||
const btn = document.getElementById('wakelock-btn');
|
||
if (!btn) return;
|
||
|
||
const supported = wakeLockSupported();
|
||
const active = Boolean(wakeLockSentinel);
|
||
btn.classList.toggle('active', active);
|
||
btn.disabled = !supported;
|
||
btn.title = supported ? t('wakeLockTitle') : t('wakeLockUnsupported');
|
||
btn.setAttribute('aria-label', btn.title);
|
||
}
|
||
|
||
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 || [], payload.meta || {});
|
||
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;
|
||
latestDashboardData = data;
|
||
latestDashboardMeta = meta;
|
||
updateLiveBadge(true, isStale);
|
||
|
||
document.getElementById('last-updated').textContent = data.ts ? String(data.ts).substring(11) : '--:--:--';
|
||
document.getElementById('age-seconds').textContent = formatResponsiveDuration(age, '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 duration-line';
|
||
else if (age > 30) ageEl.className = 'font-mono fw-bold text-warning duration-line';
|
||
else ageEl.className = 'font-mono fw-bold text-muted duration-line';
|
||
|
||
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');
|
||
}
|
||
|
||
updateEngineDuration(data, meta);
|
||
|
||
updateRemote(data);
|
||
updateDoors(data);
|
||
}
|
||
|
||
function updateResponsiveDurations() {
|
||
if (!latestDashboardData || !latestDashboardMeta) return;
|
||
|
||
document.getElementById('age-seconds').textContent =
|
||
formatResponsiveDuration(Number(latestDashboardMeta.age_seconds ?? 0), 'age');
|
||
updateEngineDuration(latestDashboardData, latestDashboardMeta);
|
||
}
|
||
|
||
function updateEngineDuration(data, meta) {
|
||
const duration = formatResponsiveDuration(Number(meta.state_duration || 0), 'state');
|
||
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;
|
||
}
|
||
|
||
function updateUsage(usage) {
|
||
if (!usage) return;
|
||
latestUsageData = usage;
|
||
|
||
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);
|
||
if (calibrationModalOpen) {
|
||
renderCalibrationSummary(usage);
|
||
}
|
||
}
|
||
|
||
function initCalibrationSettings() {
|
||
const btn = document.getElementById('settings-btn');
|
||
if (!btn || !CAR_MONITOR_SESSION?.admin) return;
|
||
|
||
btn.classList.remove('d-none');
|
||
btn.addEventListener('click', openCalibrationModal);
|
||
|
||
document.getElementById('calibration-close-btn')?.addEventListener('click', closeCalibrationModal);
|
||
document.getElementById('calibration-cancel-btn')?.addEventListener('click', closeCalibrationModal);
|
||
document.getElementById('calibration-save-btn')?.addEventListener('click', saveCalibration);
|
||
['calibration-total-usage', 'calibration-unit'].forEach(id => {
|
||
document.getElementById(id)?.addEventListener('input', updateCalibrationPreview);
|
||
document.getElementById(id)?.addEventListener('change', updateCalibrationPreview);
|
||
});
|
||
}
|
||
|
||
function openCalibrationModal() {
|
||
const overlay = document.getElementById('calibration-overlay');
|
||
if (!overlay) return;
|
||
|
||
calibrationModalOpen = true;
|
||
overlay.hidden = false;
|
||
document.body.style.overflow = 'hidden';
|
||
const anchorInput = document.getElementById('calibration-anchor-ts');
|
||
if (anchorInput && !anchorInput.value) {
|
||
anchorInput.value = formatDateTimeLocal(new Date());
|
||
}
|
||
setCalibrationMessage('T world 사용량을 입력하면 현재 월 기준점으로 저장합니다.', 'muted');
|
||
renderCalibrationSummary(latestUsageData);
|
||
fetchCalibrationState();
|
||
setTimeout(() => document.getElementById('calibration-total-usage')?.focus(), 80);
|
||
}
|
||
|
||
function closeCalibrationModal() {
|
||
const overlay = document.getElementById('calibration-overlay');
|
||
if (!overlay) return;
|
||
|
||
calibrationModalOpen = false;
|
||
overlay.hidden = true;
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
function fetchCalibrationState() {
|
||
fetch('?mode=usage-calibration', { cache: 'no-store' })
|
||
.then(res => res.json())
|
||
.then(payload => {
|
||
if (payload.status !== 'success') {
|
||
setCalibrationMessage(payload.message || '기준점 조회 실패', 'danger');
|
||
return;
|
||
}
|
||
if (payload.usage) {
|
||
latestUsageData = payload.usage;
|
||
updateUsage(payload.usage);
|
||
}
|
||
renderCalibrationHistory(payload.calibrations || []);
|
||
})
|
||
.catch(() => setCalibrationMessage('기준점 조회 실패', 'danger'));
|
||
}
|
||
|
||
function saveCalibration() {
|
||
if (calibrationSaveInFlight) return;
|
||
|
||
const totalInput = document.getElementById('calibration-total-usage');
|
||
const unitInput = document.getElementById('calibration-unit');
|
||
const anchorInput = document.getElementById('calibration-anchor-ts');
|
||
const noteInput = document.getElementById('calibration-note');
|
||
const totalUsage = totalInput?.value?.trim() || '';
|
||
if (!totalUsage) {
|
||
setCalibrationMessage('T world 사용량을 입력하세요.', 'danger');
|
||
totalInput?.focus();
|
||
return;
|
||
}
|
||
|
||
calibrationSaveInFlight = true;
|
||
const saveBtn = document.getElementById('calibration-save-btn');
|
||
if (saveBtn) saveBtn.disabled = true;
|
||
setCalibrationMessage('저장 중입니다.', 'muted');
|
||
|
||
fetch('?mode=usage-calibration', {
|
||
method: 'POST',
|
||
cache: 'no-store',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
total_usage: totalUsage,
|
||
unit: unitInput?.value || 'KB',
|
||
anchor_ts: anchorInput?.value || '',
|
||
note: noteInput?.value || '',
|
||
}),
|
||
})
|
||
.then(res => res.json())
|
||
.then(payload => {
|
||
if (payload.status !== 'success') {
|
||
setCalibrationMessage(payload.message || '저장 실패', 'danger');
|
||
return;
|
||
}
|
||
if (payload.usage) {
|
||
latestUsageData = payload.usage;
|
||
updateUsage(payload.usage);
|
||
}
|
||
renderCalibrationHistory(payload.calibrations || []);
|
||
const saved = payload.saved || {};
|
||
setCalibrationMessage(`저장 완료: ${saved.month || '-'} 배율 ${Number(saved.scale || 0).toFixed(6)}`, 'success');
|
||
fetchUsage();
|
||
})
|
||
.catch(() => setCalibrationMessage('저장 실패', 'danger'))
|
||
.finally(() => {
|
||
calibrationSaveInFlight = false;
|
||
if (saveBtn) saveBtn.disabled = false;
|
||
});
|
||
}
|
||
|
||
function renderCalibrationSummary(usage) {
|
||
const setText = (id, value) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = value;
|
||
};
|
||
if (!usage) {
|
||
setText('calibration-month', '-');
|
||
setText('calibration-meter', '-');
|
||
setText('calibration-current-scale', '-');
|
||
setText('calibration-source', '-');
|
||
updateCalibrationPreview();
|
||
return;
|
||
}
|
||
|
||
setText('calibration-month', usage.month || '-');
|
||
setText('calibration-meter', formatPreciseMb(Number(usage.carrier_adjusted_total_bytes || 0)));
|
||
setText('calibration-current-scale', Number(usage.billing_meter_scale || 1).toFixed(6));
|
||
const sourceMonth = usage.billing_meter_scale_source_month || '-';
|
||
const source = usage.billing_meter_scale_source === 'current_month_anchor'
|
||
? '현재월'
|
||
: (usage.billing_meter_scale_source === 'previous_month_anchor' ? '직전월' : '로컬');
|
||
setText('calibration-source', `${source} ${sourceMonth}`);
|
||
updateCalibrationPreview();
|
||
}
|
||
|
||
function renderCalibrationHistory(rows) {
|
||
const box = document.getElementById('calibration-history');
|
||
if (!box) return;
|
||
if (!rows.length) {
|
||
box.innerHTML = '<div class="calibration-history-row text-muted"><span>저장된 기준점 없음</span></div>';
|
||
return;
|
||
}
|
||
|
||
box.innerHTML = rows.map(row => {
|
||
const scale = Number(row.scale || 0);
|
||
return `
|
||
<div class="calibration-history-row">
|
||
<span class="font-mono">${escapeHtml(row.month || '-')}</span>
|
||
<span>${escapeHtml(String(row.anchor_ts || '-').replace(' ', ' '))}<br><span class="text-muted">${formatPreciseMb(Number(row.total_bytes || 0))} / ${formatPreciseMb(Number(row.meter_adjusted_bytes || 0))}</span></span>
|
||
<span class="font-mono text-end">${scale ? scale.toFixed(6) : '-'}</span>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function updateCalibrationPreview() {
|
||
const amount = parseFloat(String(document.getElementById('calibration-total-usage')?.value || '').replace(/,/g, ''));
|
||
const unit = document.getElementById('calibration-unit')?.value || 'KB';
|
||
const meterBytes = Number(latestUsageData?.carrier_adjusted_total_bytes || 0);
|
||
let bytes = 0;
|
||
if (Number.isFinite(amount) && amount >= 0) {
|
||
if (unit === 'MB') bytes = amount * 1024 * 1024;
|
||
else if (unit === 'KB') bytes = amount * 1024;
|
||
else bytes = amount;
|
||
}
|
||
const preview = bytes > 0 && meterBytes > 0 ? bytes / meterBytes : 0;
|
||
const el = document.getElementById('calibration-preview-scale');
|
||
if (el) el.textContent = preview ? preview.toFixed(6) : '-';
|
||
}
|
||
|
||
function setCalibrationMessage(message, type = 'muted') {
|
||
const el = document.getElementById('calibration-message');
|
||
if (!el) return;
|
||
el.textContent = message;
|
||
el.className = `calibration-message text-${type}`;
|
||
}
|
||
|
||
function formatDateTimeLocal(date) {
|
||
const pad = value => String(value).padStart(2, '0');
|
||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||
}
|
||
|
||
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 parseLocalTimestamp(value) {
|
||
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
|
||
if (!match) return null;
|
||
return new Date(
|
||
Number(match[1]),
|
||
Number(match[2]) - 1,
|
||
Number(match[3]),
|
||
Number(match[4]),
|
||
Number(match[5]),
|
||
Number(match[6])
|
||
).getTime();
|
||
}
|
||
|
||
function updateLogs(logs, meta = {}) {
|
||
const tbody = document.getElementById('log-table-body');
|
||
const serverNowMs = Number(meta.server_time || 0) > 0 ? Number(meta.server_time) * 1000 : Date.now();
|
||
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 logMs = parseLocalTimestamp(log.ts);
|
||
const ageSec = Number.isFinite(Number(log.age_seconds))
|
||
? Math.max(0, Number(log.age_seconds))
|
||
: (logMs === null ? 0 : Math.max(0, Math.floor((serverNowMs - logMs) / 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 compactDurationMode(context = 'state') {
|
||
const width = window.innerWidth || document.documentElement.clientWidth || 1024;
|
||
const target = document.getElementById(context === 'age' ? 'age-seconds' : 'val-state-timer');
|
||
const card = target ? target.closest('.glass-card') : null;
|
||
const boxWidth = card
|
||
? card.getBoundingClientRect().width
|
||
: (target ? target.getBoundingClientRect().width : 0);
|
||
const limitWidth = boxWidth || width;
|
||
|
||
if (limitWidth <= 160 || width <= 380) return 'tight';
|
||
if (limitWidth <= 230 || width <= 575) return 'compact';
|
||
return 'full';
|
||
}
|
||
|
||
function formatResponsiveDuration(sec, context = 'state') {
|
||
const mode = compactDurationMode(context);
|
||
if (mode === 'full') {
|
||
return context === 'age' ? formatAgeUnits(sec) : 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 (mode === 'tight') {
|
||
if (d > 0) return d + t('dayShort');
|
||
if (h > 0) return h + t('hourShort');
|
||
if (m > 0) return m + t('minShort');
|
||
return s + t('secShort');
|
||
}
|
||
|
||
if (d > 0) return d + t('dayShort') + (h > 0 ? ' ' + h + t('hourShort') : '');
|
||
if (h > 0) return h + t('hourShort');
|
||
if (m > 0) return m + t('minShort');
|
||
return s + t('secShort');
|
||
}
|
||
|
||
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 debounce(fn, wait) {
|
||
let timer = null;
|
||
return function (...args) {
|
||
clearTimeout(timer);
|
||
timer = setTimeout(() => fn.apply(this, args), wait);
|
||
};
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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 id="calibration-overlay" class="custom-modal-overlay" hidden>
|
||
<div class="custom-modal-panel" role="dialog" aria-modal="true" aria-labelledby="calibration-title">
|
||
<div class="custom-modal-header">
|
||
<div>
|
||
<div id="calibration-title" class="fw-bold">데이터 사용량 기준점</div>
|
||
<div class="small text-muted">T world 사용량 숫자로 월별 보정 배율을 저장합니다.</div>
|
||
</div>
|
||
<button type="button" class="btn-icon-toggle" id="calibration-close-btn" title="닫기">
|
||
<i class="fas fa-xmark"></i>
|
||
</button>
|
||
</div>
|
||
<div class="custom-modal-body">
|
||
<div class="calibration-grid">
|
||
<div class="calibration-field">
|
||
<label for="calibration-total-usage">T world 사용량</label>
|
||
<input id="calibration-total-usage" name="calibration_total_usage" type="number" inputmode="decimal" min="0" step="0.001" placeholder="예: 6396 또는 6.25" autocomplete="off">
|
||
</div>
|
||
<div class="calibration-field">
|
||
<label for="calibration-unit">단위</label>
|
||
<select id="calibration-unit" name="calibration_unit">
|
||
<option value="KB" selected>KB</option>
|
||
<option value="MB">MB</option>
|
||
<option value="B">B</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="calibration-field">
|
||
<label for="calibration-anchor-ts">기준 시각</label>
|
||
<input id="calibration-anchor-ts" name="calibration_anchor_ts" type="datetime-local" step="1" autocomplete="off">
|
||
</div>
|
||
<div class="calibration-field">
|
||
<label for="calibration-note">메모</label>
|
||
<input id="calibration-note" name="calibration_note" type="text" maxlength="80" placeholder="예: T world 사용량 조회" autocomplete="off">
|
||
</div>
|
||
<div class="calibration-summary">
|
||
<div class="calibration-summary-item">
|
||
<div class="label-text mb-1">현재 월</div>
|
||
<div id="calibration-month" class="font-mono fw-bold">-</div>
|
||
</div>
|
||
<div class="calibration-summary-item">
|
||
<div class="label-text mb-1">로컬 통신사형 누적</div>
|
||
<div id="calibration-meter" class="font-mono fw-bold">-</div>
|
||
</div>
|
||
<div class="calibration-summary-item">
|
||
<div class="label-text mb-1">현재 배율</div>
|
||
<div id="calibration-current-scale" class="font-mono fw-bold">-</div>
|
||
</div>
|
||
<div class="calibration-summary-item">
|
||
<div class="label-text mb-1">입력 예상 배율</div>
|
||
<div id="calibration-preview-scale" class="font-mono fw-bold">-</div>
|
||
</div>
|
||
<div class="calibration-summary-item">
|
||
<div class="label-text mb-1">기준 출처</div>
|
||
<div id="calibration-source" class="font-mono fw-bold">-</div>
|
||
</div>
|
||
</div>
|
||
<div id="calibration-message" class="calibration-message text-muted"></div>
|
||
<div id="calibration-history" class="calibration-history"></div>
|
||
</div>
|
||
<div class="custom-modal-footer">
|
||
<button type="button" class="btn btn-light rounded-pill px-4" id="calibration-cancel-btn">닫기</button>
|
||
<button type="button" class="btn btn-primary rounded-pill px-4" id="calibration-save-btn">저장</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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.getRegistrations()
|
||
.then(registrations => registrations.forEach(registration => registration.unregister()))
|
||
.catch(() => {});
|
||
});
|
||
}
|
||
</script>
|
||
<script src="https://chaegeon.com/log/logger.js"></script>
|
||
</body>
|
||
</html>
|