diff --git a/README.md b/README.md index fbaebdf..c05f2e2 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `control_state`: 팬 모드와 PWM 상태 - `presence_fan_policy_state`: 외출/귀가 기반 팬 정책의 마지막 확인/적용 상태 - `sensor_logs`: 온도, RPM, PWM, load, memory, disk, uptime. 과거 배터리 컬럼은 호환을 위해 유지하지만 UPS 제거 상태에서는 새 배터리값을 기록하지 않습니다. +- `network_usage_logs`: WAN 기준 누적 byte/packet 카운터와 초당 다운로드/업로드 속도, RX/TX 패킷 이력 - `power_event_states`: 저전압/스로틀링 episode 상태, 최근 지속시간, 최근 이벤트 이력 - `battery_profile_cache`: 배터리 잔여시간 계산용 장기 프로파일 캐시 - `systemd_service_cache`: 사용자 생성 systemd 서비스 목록과 최근 journal 로그의 짧은 TTL 캐시 diff --git a/config/config.php b/config/config.php index b34ef45..fc1604b 100644 --- a/config/config.php +++ b/config/config.php @@ -589,6 +589,31 @@ function bootstrap_db(): void COLLATE=utf8mb4_unicode_ci "); + $pdo->exec(" + CREATE TABLE IF NOT EXISTS network_usage_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + recorded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + iface VARCHAR(32) NOT NULL, + + rx_bytes BIGINT UNSIGNED NOT NULL, + tx_bytes BIGINT UNSIGNED NOT NULL, + rx_packets BIGINT UNSIGNED NOT NULL, + tx_packets BIGINT UNSIGNED NOT NULL, + + rx_mbps DECIMAL(12,4) NULL, + tx_mbps DECIMAL(12,4) NULL, + rx_pps DECIMAL(12,2) NULL, + tx_pps DECIMAL(12,2) NULL, + + create_ip VARCHAR(64) NULL, + + INDEX idx_recorded_at (recorded_at), + INDEX idx_iface_recorded_at (iface, recorded_at) + ) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci + "); + $pdo->exec(" CREATE TABLE IF NOT EXISTS remember_tokens ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, diff --git a/public/api.php b/public/api.php index 2377617..eb65992 100644 --- a/public/api.php +++ b/public/api.php @@ -996,6 +996,152 @@ function network_info(): array ]; } +function primary_network_interface(array $network): ?array +{ + $rows = is_array($network['interfaces'] ?? null) ? $network['interfaces'] : []; + $preferred = (string)($network['default_interface'] ?? 'eth0'); + + foreach ($rows as $row) { + if (($row['name'] ?? '') === $preferred) { + return $row; + } + } + + foreach ($rows as $row) { + if (($row['name'] ?? '') === 'eth0') { + return $row; + } + } + + foreach ($rows as $row) { + if (($row['carrier'] ?? '') === 'up' && ($row['name'] ?? '') !== 'br0') { + return $row; + } + } + + return $rows[0] ?? null; +} + +function add_network_usage_log(array $network): void +{ + $iface = primary_network_interface($network); + if (!$iface) { + return; + } + + $name = (string)($iface['name'] ?? ''); + $rxBytes = isset($iface['rx_bytes']) ? (int)$iface['rx_bytes'] : -1; + $txBytes = isset($iface['tx_bytes']) ? (int)$iface['tx_bytes'] : -1; + $rxPackets = isset($iface['rx_packets']) ? (int)$iface['rx_packets'] : -1; + $txPackets = isset($iface['tx_packets']) ? (int)$iface['tx_packets'] : -1; + if ($name === '' || $rxBytes < 0 || $txBytes < 0 || $rxPackets < 0 || $txPackets < 0) { + return; + } + + $pdo = db(); + $stmt = $pdo->prepare(" + SELECT + recorded_at, + rx_bytes, + tx_bytes, + rx_packets, + tx_packets, + TIMESTAMPDIFF(MICROSECOND, recorded_at, NOW(3)) AS elapsed_us + FROM network_usage_logs + WHERE iface = :iface + ORDER BY id DESC + LIMIT 1 + "); + $stmt->execute([':iface' => $name]); + $previous = $stmt->fetch() ?: null; + + $rxMbps = null; + $txMbps = null; + $rxPps = null; + $txPps = null; + if ($previous) { + $seconds = max(0.0, (float)($previous['elapsed_us'] ?? 0) / 1000000); + if ($seconds < 0.8) { + return; + } + + $rxDelta = $rxBytes - (int)$previous['rx_bytes']; + $txDelta = $txBytes - (int)$previous['tx_bytes']; + $rxPacketDelta = $rxPackets - (int)$previous['rx_packets']; + $txPacketDelta = $txPackets - (int)$previous['tx_packets']; + if ($seconds > 0 && $seconds <= 60 && $rxDelta >= 0 && $txDelta >= 0 && $rxPacketDelta >= 0 && $txPacketDelta >= 0) { + $rxMbps = round(($rxDelta * 8) / $seconds / 1000000, 4); + $txMbps = round(($txDelta * 8) / $seconds / 1000000, 4); + $rxPps = round($rxPacketDelta / $seconds, 2); + $txPps = round($txPacketDelta / $seconds, 2); + } + } + + $stmt = $pdo->prepare(" + INSERT INTO network_usage_logs + ( + iface, + rx_bytes, + tx_bytes, + rx_packets, + tx_packets, + rx_mbps, + tx_mbps, + rx_pps, + tx_pps, + create_ip + ) + VALUES + ( + :iface, + :rx_bytes, + :tx_bytes, + :rx_packets, + :tx_packets, + :rx_mbps, + :tx_mbps, + :rx_pps, + :tx_pps, + :create_ip + ) + "); + $stmt->execute([ + ':iface' => $name, + ':rx_bytes' => $rxBytes, + ':tx_bytes' => $txBytes, + ':rx_packets' => $rxPackets, + ':tx_packets' => $txPackets, + ':rx_mbps' => $rxMbps, + ':tx_mbps' => $txMbps, + ':rx_pps' => $rxPps, + ':tx_pps' => $txPps, + ':create_ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli', + ]); +} + +function network_usage_history(int $limit = 240): array +{ + $limit = max(1, min(1500, $limit)); + $stmt = db()->query(" + SELECT + recorded_at AS time, + iface, + rx_mbps, + tx_mbps, + rx_pps, + tx_pps + FROM network_usage_logs + WHERE rx_mbps IS NOT NULL + AND tx_mbps IS NOT NULL + AND rx_pps IS NOT NULL + AND tx_pps IS NOT NULL + ORDER BY id DESC + LIMIT {$limit} + "); + + return array_reverse($stmt->fetchAll()); +} + function latest_sensor(): array { $stmt = db()->query(" @@ -4382,6 +4528,9 @@ function collect_snapshot(bool $applyFan = true): array $processes = process_resource_data((int)setting_value('display.process_limit')); $fanSpike = fan_spike_analysis($history, $fan, $system, $processes); + $network = network_info(); + add_network_usage_log($network); + $networkHistory = network_usage_history(240); $shouldLogNotice = !empty($fanSpike['alert_started']) || (!empty($fanSpike['active']) && (bool)setting_value('notice.log_during_alert')); @@ -4402,7 +4551,8 @@ function collect_snapshot(bool $applyFan = true): array 'battery' => $battery, 'wifi' => wifi_data(), - 'network' => network_info(), + 'network' => $network, + 'network_history' => $networkHistory, 'history' => $history, 'processes' => $processes, 'custom_services' => custom_systemd_services(), diff --git a/public/assets/app.js b/public/assets/app.js index 01777db..f4de92a 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -105,8 +105,6 @@ settingsOpen: false, settingsDirty: false, customServiceScrollHoldUntil: 0, - networkLast: null, - networkHistory: [], }; const storageKeys = { @@ -1835,74 +1833,6 @@ } } - function primaryNetworkInterface(network) { - const rows = Array.isArray(network?.interfaces) ? network.interfaces : []; - const preferred = String(network?.default_interface || 'eth0'); - return rows.find(row => row.name === preferred) - || rows.find(row => row.name === 'eth0') - || rows.find(row => row.carrier === 'up' && row.name !== 'br0') - || rows[0] - || null; - } - - function networkCounter(row, key) { - const value = Number(row?.[key]); - return Number.isFinite(value) && value >= 0 ? value : null; - } - - function pushNetworkSample(data) { - const iface = primaryNetworkInterface(data.network); - if (!iface) return; - - const nowMs = Number(data.time_ms || Date.now()); - const sample = { - iface: String(iface.name || ''), - time_ms: nowMs, - rx_bytes: networkCounter(iface, 'rx_bytes'), - tx_bytes: networkCounter(iface, 'tx_bytes'), - rx_packets: networkCounter(iface, 'rx_packets'), - tx_packets: networkCounter(iface, 'tx_packets'), - }; - - if (!sample.iface || sample.rx_bytes === null || sample.tx_bytes === null || sample.rx_packets === null || sample.tx_packets === null) { - return; - } - - const previous = state.networkLast; - state.networkLast = sample; - - if (!previous || previous.iface !== sample.iface || sample.time_ms <= previous.time_ms) { - return; - } - - const seconds = (sample.time_ms - previous.time_ms) / 1000; - if (seconds <= 0 || seconds > 30) { - return; - } - - const delta = key => sample[key] - previous[key]; - const rxBytes = delta('rx_bytes'); - const txBytes = delta('tx_bytes'); - const rxPackets = delta('rx_packets'); - const txPackets = delta('tx_packets'); - - if (rxBytes < 0 || txBytes < 0 || rxPackets < 0 || txPackets < 0) { - return; - } - - state.networkHistory.push({ - time: String(data.generated_at || '').replace('T', ' ') || new Date(sample.time_ms).toISOString().slice(0, 19).replace('T', ' '), - rx_mbps: (rxBytes * 8) / seconds / 1000000, - tx_mbps: (txBytes * 8) / seconds / 1000000, - rx_pps: rxPackets / seconds, - tx_pps: txPackets / seconds, - }); - - if (state.networkHistory.length > 180) { - state.networkHistory.splice(0, state.networkHistory.length - 180); - } - } - function renderSystemStatus(data) { const system = data.system || {}; const disk = system.disk || {}; @@ -2379,6 +2309,13 @@ return xTickMarkers.get(index) || ''; }; const xTickCallback = (_value, index) => isXGridTick(index) ? xTickLabel(index) : ''; + const yTickCallback = value => { + const numeric = Number(value); + const display = Number.isFinite(numeric) + ? numeric.toLocaleString(undefined, { maximumFractionDigits: 2 }) + : String(value); + return suffix ? `${display}${suffix}` : display; + }; const xGridColor = context => { const index = Number(context.index ?? context.tick?.value ?? -1); return isXGridTick(index) ? 'rgba(203,213,225,.18)' : 'rgba(203,213,225,0)'; @@ -2481,7 +2418,7 @@ y: { min: 0, ...scaleOptions, - ticks: { color: '#8d98aa', maxTicksLimit: 4 }, + ticks: { color: '#8d98aa', maxTicksLimit: 4, callback: yTickCallback }, grid: { color: 'rgba(255,255,255,.045)', drawTicks: false }, border: { display: false }, }, @@ -2496,6 +2433,7 @@ c.data.datasets = datasets; c.options.scales.x.ticks.callback = xTickCallback; c.options.scales.x.grid.color = xGridColor; + c.options.scales.y.ticks.callback = yTickCallback; c.options.scales.y.min = scaleOptions.min ?? 0; c.options.scales.y.max = scaleOptions.max; c.update('none'); @@ -2557,7 +2495,7 @@ chart('batteryVoltageChart', 'BATTERYV', rows, 'battery_voltage', '#14b8a6', 'V', dynamicScale(rows, 'battery_voltage', { minSpan: 0.2, padding: 0.06 })); } - const networkRows = state.networkHistory.slice(-180); + const networkRows = (data.network_history || []).slice(-180); chart('netRxChart', 'NETRX', networkRows, 'rx_mbps', '#0ea5e9', ' Mbit/s', dynamicScale(networkRows, 'rx_mbps', { minSpan: 1, padding: 0.3 })); chart('netTxChart', 'NETTX', networkRows, 'tx_mbps', '#22c55e', ' Mbit/s', dynamicScale(networkRows, 'tx_mbps', { minSpan: 1, padding: 0.3 })); chart('netRxPacketsChart', 'NETRXPKT', networkRows, 'rx_pps', '#f97316', ' pps', dynamicScale(networkRows, 'rx_pps', { minSpan: 10, padding: 5 })); @@ -2582,7 +2520,6 @@ renderSettings(data.settings); } } - pushNetworkSample(data); renderTop(data); renderSystemStatus(data); renderWifi(data);