데이터 사용량 기준점 입력 모달 추가
This commit is contained in:
+662
-20
@@ -62,6 +62,21 @@ function db_ensure_data_usage_schema(PDO $pdo): void
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -245,8 +260,8 @@ function monthly_data_usage(PDO $pdo): array
|
||||
$projectionStart = $monthStart->getTimestamp();
|
||||
}
|
||||
$measuredSeconds = max(1, $now->getTimestamp() - $projectionStart);
|
||||
$currentBilling = current_billing_usage($monthStart->format('Y-m'));
|
||||
$billingScaleInfo = billing_meter_scale_info($monthStart->format('Y-m'), $currentBilling);
|
||||
$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);
|
||||
@@ -523,22 +538,61 @@ function calibrated_usage_range(PDO $pdo, DateTimeInterface $start, DateTimeInte
|
||||
];
|
||||
}
|
||||
|
||||
function current_billing_usage(string $month): ?array
|
||||
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 [
|
||||
return billing_usage_row([
|
||||
'month' => $month,
|
||||
'anchor_ts' => $row['anchor_ts'] ?? null,
|
||||
'total_bytes' => (int)($row['total_bytes'] ?? 0),
|
||||
'total_mb' => round(bytes_to_mb((int)($row['total_bytes'] ?? 0)), 6),
|
||||
'meter_adjusted_bytes' => (int)($row['meter_adjusted_bytes'] ?? 0),
|
||||
'included_bytes' => (int)($row['included_bytes'] ?? DATA_MONTHLY_INCLUDED_BYTES),
|
||||
'included_mb' => round(bytes_to_mb((int)($row['included_bytes'] ?? DATA_MONTHLY_INCLUDED_BYTES)), 6),
|
||||
'coupon_registered_at' => $row['coupon_registered_at'] ?? null,
|
||||
];
|
||||
'source' => 'constant',
|
||||
'note' => '코드 기준점',
|
||||
'created_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
function billing_meter_scale(?array $currentBilling): float
|
||||
@@ -555,7 +609,45 @@ function billing_meter_scale(?array $currentBilling): float
|
||||
return (int)($currentBilling['total_bytes'] ?? 0) / $meterAdjustedBytes;
|
||||
}
|
||||
|
||||
function billing_meter_scale_info(string $month, ?array $currentBilling): array
|
||||
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) {
|
||||
@@ -567,24 +659,14 @@ function billing_meter_scale_info(string $month, ?array $currentBilling): array
|
||||
];
|
||||
}
|
||||
|
||||
$fallbackMonth = null;
|
||||
$fallbackBilling = null;
|
||||
foreach (DATA_CURRENT_BILLING_USAGE_BYTES as $billingMonth => $row) {
|
||||
if ($billingMonth >= $month || ($row['meter_adjusted_bytes'] ?? 0) <= 0) {
|
||||
continue;
|
||||
}
|
||||
if ($fallbackMonth === null || $billingMonth > $fallbackMonth) {
|
||||
$fallbackMonth = $billingMonth;
|
||||
$fallbackBilling = current_billing_usage($billingMonth);
|
||||
}
|
||||
}
|
||||
$fallbackBilling = previous_billing_usage($month, $pdo);
|
||||
|
||||
$fallbackScale = billing_meter_scale($fallbackBilling);
|
||||
if ($fallbackBilling && $fallbackScale > 0) {
|
||||
return [
|
||||
'scale' => $fallbackScale,
|
||||
'source' => 'previous_month_anchor',
|
||||
'source_month' => $fallbackMonth,
|
||||
'source_month' => $fallbackBilling['month'] ?? null,
|
||||
'source_billing' => $fallbackBilling,
|
||||
];
|
||||
}
|
||||
@@ -625,6 +707,149 @@ function billing_calibration(?array $currentBilling, array $billingScaleInfo): a
|
||||
];
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -853,7 +1078,38 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -1191,6 +1447,132 @@ car_monitor_require_access(false);
|
||||
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); }
|
||||
@@ -1449,6 +1831,9 @@ car_monitor_require_access(false);
|
||||
<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>
|
||||
|
||||
@@ -1859,6 +2244,7 @@ car_monitor_require_access(false);
|
||||
|
||||
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)' },
|
||||
@@ -1880,6 +2266,9 @@ car_monitor_require_access(false);
|
||||
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_LINK_PATH_TITLE = {
|
||||
'/': '채건닷컴',
|
||||
@@ -1892,6 +2281,7 @@ car_monitor_require_access(false);
|
||||
initLanguage();
|
||||
initTheme();
|
||||
initWakeLock();
|
||||
initCalibrationSettings();
|
||||
initChart();
|
||||
fetchData();
|
||||
refreshTimer = setInterval(fetchData, 1000);
|
||||
@@ -2299,6 +2689,7 @@ car_monitor_require_access(false);
|
||||
|
||||
function updateUsage(usage) {
|
||||
if (!usage) return;
|
||||
latestUsageData = usage;
|
||||
|
||||
const total = Number(usage.total_bytes || 0);
|
||||
const billingTotal = Number(usage.billing_estimated_current_bytes ?? total);
|
||||
@@ -2328,6 +2719,191 @@ car_monitor_require_access(false);
|
||||
|
||||
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) {
|
||||
@@ -2582,6 +3158,72 @@ car_monitor_require_access(false);
|
||||
});
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user