diff --git a/README.md b/README.md index 9455a39..fbaebdf 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - 서울 HA의 휴대폰 device tracker로 외출/귀가를 감지해 상태 변화 시 1회만 팬 모드 자동 전환 - 오버레이 설정 모달에서 보안 정책, 팬 자동곡선, 이벤트 판정, 팬 이상감지 파라미터 조정 - 설정 모달에서 라즈베리파이 정품 7인치 DSI 터치 디스플레이 상태 확인, 밝기/백라이트 즉시 적용, 콘솔 회전, 터치 좌표 보정, LCD/터치 비활성화 부팅 설정 조정 -- CPU 온도, 팬 RPM 핵심 차트 -- RP1 온도, 팬 효율, CPU 전력 상세 차트는 접힘 영역에서 확인 +- CPU 온도, RP1 온도, CPU 전력, 팬 RPM 센서 이력 차트 +- DHCP client 목록 아래에서 WAN 기준 다운로드/업로드 속도와 RX/TX 패킷 이력 차트 표시 - UPS/배터리 잔여 시간 기능은 장치 제거 후 비활성 상태로 두고, 배터리 센서 조회와 장기 학습 계산은 돌리지 않음 - 라즈베리파이 저전압/스로틀링 현재 상태, 복구 중/반복 발생 판정, 부팅 후 이력, 최근 감지 시각, 지속시간, 최근 10분 통계 표시 - WiFi/LAN client 목록과 2.4G/5G/LAN client 수 표시 diff --git a/public/api.php b/public/api.php index d1dde1b..2377617 100644 --- a/public/api.php +++ b/public/api.php @@ -947,6 +947,11 @@ function os_info(): array function network_info(): array { $rows = []; + $defaultInterface = ''; + $route = trim((string)sh(['/usr/sbin/ip', 'route', 'show', 'default'], false, 3)['out']); + if (preg_match('/\bdev\s+([^\s]+)/', $route, $matches)) { + $defaultInterface = (string)$matches[1]; + } foreach (glob('/sys/class/net/*') ?: [] as $dir) { $name = basename($dir); @@ -957,22 +962,38 @@ function network_info(): array $rx = (int)first_readable([$dir . '/statistics/rx_bytes']); $tx = (int)first_readable([$dir . '/statistics/tx_bytes']); + $rxPackets = (int)first_readable([$dir . '/statistics/rx_packets']); + $txPackets = (int)first_readable([$dir . '/statistics/tx_packets']); + $rxErrors = (int)first_readable([$dir . '/statistics/rx_errors']); + $txErrors = (int)first_readable([$dir . '/statistics/tx_errors']); + $rxDropped = (int)first_readable([$dir . '/statistics/rx_dropped']); + $txDropped = (int)first_readable([$dir . '/statistics/tx_dropped']); $rows[] = [ 'name' => $name, + 'default' => $name === $defaultInterface, 'state' => first_readable([$dir . '/operstate']) ?: 'unknown', 'carrier' => first_readable([$dir . '/carrier']) === '1' ? 'up' : 'down', 'mac' => first_readable([$dir . '/address']) ?: 'N/A', 'mtu' => first_readable([$dir . '/mtu']) ?: 'N/A', 'rx_bytes' => $rx, 'tx_bytes' => $tx, + 'rx_packets' => $rxPackets, + 'tx_packets' => $txPackets, + 'rx_errors' => $rxErrors, + 'tx_errors' => $txErrors, + 'rx_dropped' => $rxDropped, + 'tx_dropped' => $txDropped, 'rx_human' => bytes_human($rx), 'tx_human' => bytes_human($tx), 'ipv4' => trim(sh(['/usr/sbin/ip', '-4', '-o', 'addr', 'show', 'dev', $name], false, 3)['out']) ?: 'N/A', ]; } - return $rows; + return [ + 'default_interface' => $defaultInterface, + 'interfaces' => $rows, + ]; } function latest_sensor(): array @@ -4381,6 +4402,7 @@ function collect_snapshot(bool $applyFan = true): array 'battery' => $battery, 'wifi' => wifi_data(), + 'network' => network_info(), 'history' => $history, 'processes' => $processes, 'custom_services' => custom_systemd_services(), diff --git a/public/assets/app.js b/public/assets/app.js index bec9a2f..01777db 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -105,6 +105,8 @@ settingsOpen: false, settingsDirty: false, customServiceScrollHoldUntil: 0, + networkLast: null, + networkHistory: [], }; const storageKeys = { @@ -160,6 +162,11 @@ temperature: 'CPU Temperature', rp1Temp: 'RP1 Temp', cpuWatt: 'CPU Watt', + networkHistory: 'Network History', + downloadPerSecond: 'Download / s', + uploadPerSecond: 'Upload / s', + rxPacketsPerSecond: 'RX Packets / s', + txPacketsPerSecond: 'TX Packets / s', remaining: 'Remaining', batteryVoltage: 'Battery Voltage', wifiClients: 'DHCP Clients', @@ -355,6 +362,11 @@ temperature: 'CPU 온도', rp1Temp: 'RP1 온도', cpuWatt: 'CPU 전력', + networkHistory: '네트워크 이력', + downloadPerSecond: '초당 다운로드 속도', + uploadPerSecond: '초당 업로드 속도', + rxPacketsPerSecond: '초당 RX 패킷', + txPacketsPerSecond: '초당 TX 패킷', remaining: '잔여 시간', batteryVoltage: '배터리 전압', wifiClients: 'DHCP 클라이언트', @@ -1823,6 +1835,74 @@ } } + 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 || {}; @@ -2476,6 +2556,12 @@ if (document.getElementById('batteryVoltageChart')) { chart('batteryVoltageChart', 'BATTERYV', rows, 'battery_voltage', '#14b8a6', 'V', dynamicScale(rows, 'battery_voltage', { minSpan: 0.2, padding: 0.06 })); } + + const networkRows = state.networkHistory.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 })); + chart('netTxPacketsChart', 'NETTXPKT', networkRows, 'tx_pps', '#a855f7', ' pps', dynamicScale(networkRows, 'tx_pps', { minSpan: 10, padding: 5 })); } function resizeChartsSoon() { @@ -2496,6 +2582,7 @@ renderSettings(data.settings); } } + pushNetworkSample(data); renderTop(data); renderSystemStatus(data); renderWifi(data); diff --git a/public/index.php b/public/index.php index affcc37..b0d1326 100644 --- a/public/index.php +++ b/public/index.php @@ -284,6 +284,16 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da} +