데이터 사용량 기준점 입력 모달 추가
This commit is contained in:
@@ -31,6 +31,7 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저
|
|||||||
- `api.php?action=command`: 차량 명령 전송
|
- `api.php?action=command`: 차량 명령 전송
|
||||||
- `monitor.php?mode=ajax`: monitor 상태 AJAX
|
- `monitor.php?mode=ajax`: monitor 상태 AJAX
|
||||||
- `monitor.php?mode=usage`: 데이터 사용량 AJAX
|
- `monitor.php?mode=usage`: 데이터 사용량 AJAX
|
||||||
|
- `monitor.php?mode=usage-calibration`: 관리자 전용 데이터 사용량 보정 기준점 조회/저장
|
||||||
|
|
||||||
## Monitor 경과 시간
|
## Monitor 경과 시간
|
||||||
|
|
||||||
@@ -130,7 +131,8 @@ Worker 교체나 예외 종료로 `processing`, `ui_timeout_pending` 상태가
|
|||||||
- 명령별 rate limit과 감사 로그를 유지합니다.
|
- 명령별 rate limit과 감사 로그를 유지합니다.
|
||||||
- 통신사 기준 데이터 사용량 보정값을 주기적으로 확인합니다. 2026-06-30 15:15 T world 실측 114.5MB(117,250.5KB) 기준 2026-06 보정값은 통신사 누적 120,064,512 bytes, 통신사 과금 단위 추정 누적 129,148,416 bytes, 배율 0.929663입니다.
|
- 통신사 기준 데이터 사용량 보정값을 주기적으로 확인합니다. 2026-06-30 15:15 T world 실측 114.5MB(117,250.5KB) 기준 2026-06 보정값은 통신사 누적 120,064,512 bytes, 통신사 과금 단위 추정 누적 129,148,416 bytes, 배율 0.929663입니다.
|
||||||
- 2026-07-02 12:42 T world 실측 6.25MB(6,396KB) 기준 2026-07 보정값은 통신사 누적 6,549,504 bytes, 통신사 과금 단위 추정 누적 7,169,536 bytes, 배율 0.913519입니다.
|
- 2026-07-02 12:42 T world 실측 6.25MB(6,396KB) 기준 2026-07 보정값은 통신사 누적 6,549,504 bytes, 통신사 과금 단위 추정 누적 7,169,536 bytes, 배율 0.913519입니다.
|
||||||
- 해당 월의 T world 기준점이 있으면 그 월 배율을 우선 사용하고, 아직 기준점이 없는 새 달은 가장 최근 월 배율을 임시로 사용합니다. 새 기준점이 기록되면 해당 월 배율로 다시 계산합니다.
|
- 관리자 인증 상태에서는 상단 우측 설정 아이콘으로 T world 사용량 기준점을 저장할 수 있습니다. 입력값은 `car_usage_calibrations` 테이블에 저장되고, 같은 월에서는 가장 최근 기준 시각의 값을 우선 사용합니다.
|
||||||
|
- 해당 월의 T world 기준점이 DB나 코드 기준점에 있으면 그 월 배율을 우선 사용하고, 아직 기준점이 없는 새 달은 가장 최근 월 배율을 임시로 사용합니다. 새 기준점이 기록되면 해당 월 배율로 다시 계산합니다.
|
||||||
- 2026-06 기준 시각의 로컬 계측 범위는 2026-06-01 00:00:00부터 2026-06-30 15:15:00 직전까지이며, 마지막 로컬 사용량 기록은 2026-06-30 15:14:59입니다.
|
- 2026-06 기준 시각의 로컬 계측 범위는 2026-06-01 00:00:00부터 2026-06-30 15:15:00 직전까지이며, 마지막 로컬 사용량 기록은 2026-06-30 15:14:59입니다.
|
||||||
- 데이터 사용량은 TCP payload 합계도 보존하지만 화면의 현재 사용량, 잔여 사용량, 예상 사용량, 예상 요금은 0.5KB 과금 단위로 올림 처리한 통신사형 누적값을 우선 사용합니다. 순수 연결 실패와 마지막 성공 이후 60초를 넘긴 수신 공백은 기존처럼 제외합니다.
|
- 데이터 사용량은 TCP payload 합계도 보존하지만 화면의 현재 사용량, 잔여 사용량, 예상 사용량, 예상 요금은 0.5KB 과금 단위로 올림 처리한 통신사형 누적값을 우선 사용합니다. 순수 연결 실패와 마지막 성공 이후 60초를 넘긴 수신 공백은 기존처럼 제외합니다.
|
||||||
- 데이터 사용량 집계는 성공 응답 평균을 SQL로 먼저 구하고, 월간/일간 행은 PHP에서 한 줄씩 스트리밍 처리합니다. 기존 보정 조건은 유지하면서 `fetchAll()` 메모리 사용을 제거해 PHP-FPM `memory_limit=128M`에서도 동작하도록 합니다.
|
- 데이터 사용량 집계는 성공 응답 평균을 SQL로 먼저 구하고, 월간/일간 행은 PHP에서 한 줄씩 스트리밍 처리합니다. 기존 보정 조건은 유지하면서 `fetchAll()` 메모리 사용을 제거해 PHP-FPM `memory_limit=128M`에서도 동작하도록 합니다.
|
||||||
|
|||||||
+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("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;
|
$done = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,8 +260,8 @@ function monthly_data_usage(PDO $pdo): array
|
|||||||
$projectionStart = $monthStart->getTimestamp();
|
$projectionStart = $monthStart->getTimestamp();
|
||||||
}
|
}
|
||||||
$measuredSeconds = max(1, $now->getTimestamp() - $projectionStart);
|
$measuredSeconds = max(1, $now->getTimestamp() - $projectionStart);
|
||||||
$currentBilling = current_billing_usage($monthStart->format('Y-m'));
|
$currentBilling = current_billing_usage($monthStart->format('Y-m'), $pdo);
|
||||||
$billingScaleInfo = billing_meter_scale_info($monthStart->format('Y-m'), $currentBilling);
|
$billingScaleInfo = billing_meter_scale_info($monthStart->format('Y-m'), $currentBilling, $pdo);
|
||||||
$billingScale = $billingScaleInfo['scale'];
|
$billingScale = $billingScaleInfo['scale'];
|
||||||
$billingCurrentBytes = (int)round($carrierAdjustedTotalBytes * $billingScale);
|
$billingCurrentBytes = (int)round($carrierAdjustedTotalBytes * $billingScale);
|
||||||
$projectedBytes = (int)round($carrierAdjustedTotalBytes / $measuredSeconds * $monthSeconds);
|
$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])) {
|
if (!isset(DATA_CURRENT_BILLING_USAGE_BYTES[$month])) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$row = DATA_CURRENT_BILLING_USAGE_BYTES[$month];
|
$row = DATA_CURRENT_BILLING_USAGE_BYTES[$month];
|
||||||
return [
|
return billing_usage_row([
|
||||||
'month' => $month,
|
'month' => $month,
|
||||||
|
'anchor_ts' => $row['anchor_ts'] ?? null,
|
||||||
'total_bytes' => (int)($row['total_bytes'] ?? 0),
|
'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),
|
'meter_adjusted_bytes' => (int)($row['meter_adjusted_bytes'] ?? 0),
|
||||||
'included_bytes' => (int)($row['included_bytes'] ?? DATA_MONTHLY_INCLUDED_BYTES),
|
'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,
|
'coupon_registered_at' => $row['coupon_registered_at'] ?? null,
|
||||||
];
|
'source' => 'constant',
|
||||||
|
'note' => '코드 기준점',
|
||||||
|
'created_at' => null,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function billing_meter_scale(?array $currentBilling): float
|
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;
|
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);
|
$currentScale = billing_meter_scale($currentBilling);
|
||||||
if ($currentBilling && $currentScale > 0) {
|
if ($currentBilling && $currentScale > 0) {
|
||||||
@@ -567,24 +659,14 @@ function billing_meter_scale_info(string $month, ?array $currentBilling): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$fallbackMonth = null;
|
$fallbackBilling = previous_billing_usage($month, $pdo);
|
||||||
$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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$fallbackScale = billing_meter_scale($fallbackBilling);
|
$fallbackScale = billing_meter_scale($fallbackBilling);
|
||||||
if ($fallbackBilling && $fallbackScale > 0) {
|
if ($fallbackBilling && $fallbackScale > 0) {
|
||||||
return [
|
return [
|
||||||
'scale' => $fallbackScale,
|
'scale' => $fallbackScale,
|
||||||
'source' => 'previous_month_anchor',
|
'source' => 'previous_month_anchor',
|
||||||
'source_month' => $fallbackMonth,
|
'source_month' => $fallbackBilling['month'] ?? null,
|
||||||
'source_billing' => $fallbackBilling,
|
'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
|
function json_response(array $payload, int $status = 200): void
|
||||||
{
|
{
|
||||||
http_response_code($status);
|
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);
|
car_monitor_require_access(false);
|
||||||
|
$carMonitorSession = car_monitor_launcher_session();
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko" data-bs-theme="light">
|
<html lang="ko" data-bs-theme="light">
|
||||||
@@ -1191,6 +1447,132 @@ car_monitor_require_access(false);
|
|||||||
border-color: rgba(34, 197, 94, 0.45);
|
border-color: rgba(34, 197, 94, 0.45);
|
||||||
color: var(--accent-green);
|
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 {
|
@keyframes pulse-ring {
|
||||||
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
|
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()">
|
<button class="btn-icon-toggle shadow-sm" id="theme-btn" onclick="toggleTheme()">
|
||||||
<i class="fas fa-moon"></i>
|
<i class="fas fa-moon"></i>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
@@ -1859,6 +2244,7 @@ car_monitor_require_access(false);
|
|||||||
|
|
||||||
let currentLang = 'ko';
|
let currentLang = 'ko';
|
||||||
const DEFAULT_USAGE_REFRESH_INTERVAL_SEC = 10;
|
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 = {
|
const CMD_MAP = {
|
||||||
se: { label: 'cmdStatus', icon: 'fa-search', bg: 'var(--box-bg-blue)', color: 'var(--accent-blue)' },
|
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 wakeLockWanted = false;
|
||||||
let latestDashboardData = null;
|
let latestDashboardData = null;
|
||||||
let latestDashboardMeta = null;
|
let latestDashboardMeta = null;
|
||||||
|
let latestUsageData = null;
|
||||||
|
let calibrationModalOpen = false;
|
||||||
|
let calibrationSaveInFlight = false;
|
||||||
const FALLBACK_BACK_TARGET = 'https://chaegeon.com/';
|
const FALLBACK_BACK_TARGET = 'https://chaegeon.com/';
|
||||||
const BACK_LINK_PATH_TITLE = {
|
const BACK_LINK_PATH_TITLE = {
|
||||||
'/': '채건닷컴',
|
'/': '채건닷컴',
|
||||||
@@ -1892,6 +2281,7 @@ car_monitor_require_access(false);
|
|||||||
initLanguage();
|
initLanguage();
|
||||||
initTheme();
|
initTheme();
|
||||||
initWakeLock();
|
initWakeLock();
|
||||||
|
initCalibrationSettings();
|
||||||
initChart();
|
initChart();
|
||||||
fetchData();
|
fetchData();
|
||||||
refreshTimer = setInterval(fetchData, 1000);
|
refreshTimer = setInterval(fetchData, 1000);
|
||||||
@@ -2299,6 +2689,7 @@ car_monitor_require_access(false);
|
|||||||
|
|
||||||
function updateUsage(usage) {
|
function updateUsage(usage) {
|
||||||
if (!usage) return;
|
if (!usage) return;
|
||||||
|
latestUsageData = usage;
|
||||||
|
|
||||||
const total = Number(usage.total_bytes || 0);
|
const total = Number(usage.total_bytes || 0);
|
||||||
const billingTotal = Number(usage.billing_estimated_current_bytes ?? total);
|
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.style.width = percent.toFixed(2) + '%';
|
||||||
fill.classList.toggle('warning', overBytes > 0 || projectedOverBytes > 0 || percent >= 85);
|
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) {
|
function updateRemote(data) {
|
||||||
@@ -2582,6 +3158,72 @@ car_monitor_require_access(false);
|
|||||||
});
|
});
|
||||||
</script>
|
</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 fade" id="rawModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||||
<div class="modal-content glass-card">
|
<div class="modal-content glass-card">
|
||||||
|
|||||||
Reference in New Issue
Block a user