From 9cd5160f0397fd846449f11605bd6ee247ee5160 Mon Sep 17 00:00:00 2001 From: seo Date: Sat, 25 Jul 2026 22:32:54 +0900 Subject: [PATCH] =?UTF-8?q?=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=20=EC=B0=A8=ED=8A=B8=20=EC=9B=90=EB=B3=B8=20?= =?UTF-8?q?=EC=84=A0=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- public/api.php | 30 +++++- public/assets/app.js | 212 ++++++++++++++++++++++++++++++++++++++++--- public/index.php | 8 +- 4 files changed, 230 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index c05f2e2..7216d6f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - 오버레이 설정 모달에서 보안 정책, 팬 자동곡선, 이벤트 판정, 팬 이상감지 파라미터 조정 - 설정 모달에서 라즈베리파이 정품 7인치 DSI 터치 디스플레이 상태 확인, 밝기/백라이트 즉시 적용, 콘솔 회전, 터치 좌표 보정, LCD/터치 비활성화 부팅 설정 조정 - CPU 온도, RP1 온도, CPU 전력, 팬 RPM 센서 이력 차트 -- DHCP client 목록 아래에서 WAN 기준 다운로드/업로드 속도와 RX/TX 패킷 이력 차트 표시 +- DHCP client 목록 아래에서 WAN과 브릿지 `br0` 기준 다운로드/업로드 속도와 RX/TX 패킷 이력 차트 표시 - UPS/배터리 잔여 시간 기능은 장치 제거 후 비활성 상태로 두고, 배터리 센서 조회와 장기 학습 계산은 돌리지 않음 - 라즈베리파이 저전압/스로틀링 현재 상태, 복구 중/반복 발생 판정, 부팅 후 이력, 최근 감지 시각, 지속시간, 최근 10분 통계 표시 - WiFi/LAN client 목록과 2.4G/5G/LAN client 수 표시 @@ -76,7 +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 패킷 이력 +- `network_usage_logs`: WAN과 브릿지 `br0` 기준 누적 byte/packet 카운터와 초당 다운로드/업로드 속도, RX/TX 패킷 이력 - `power_event_states`: 저전압/스로틀링 episode 상태, 최근 지속시간, 최근 이벤트 이력 - `battery_profile_cache`: 배터리 잔여시간 계산용 장기 프로파일 캐시 - `systemd_service_cache`: 사용자 생성 systemd 서비스 목록과 최근 journal 로그의 짧은 TTL 캐시 diff --git a/public/api.php b/public/api.php index eb65992..ce63e28 100644 --- a/public/api.php +++ b/public/api.php @@ -1022,13 +1022,26 @@ function primary_network_interface(array $network): ?array return $rows[0] ?? null; } -function add_network_usage_log(array $network): void +function network_log_interfaces(array $network): array { - $iface = primary_network_interface($network); - if (!$iface) { - return; + $targets = []; + $primary = primary_network_interface($network); + if ($primary) { + $targets[(string)($primary['name'] ?? '')] = $primary; } + foreach ((array)($network['interfaces'] ?? []) as $row) { + if (($row['name'] ?? '') === 'br0') { + $targets['br0'] = $row; + } + } + + unset($targets['']); + return array_values($targets); +} + +function add_network_usage_log_for_interface(array $iface): void +{ $name = (string)($iface['name'] ?? ''); $rxBytes = isset($iface['rx_bytes']) ? (int)$iface['rx_bytes'] : -1; $txBytes = isset($iface['tx_bytes']) ? (int)$iface['tx_bytes'] : -1; @@ -1119,6 +1132,13 @@ function add_network_usage_log(array $network): void ]); } +function add_network_usage_log(array $network): void +{ + foreach (network_log_interfaces($network) as $iface) { + add_network_usage_log_for_interface($iface); + } +} + function network_usage_history(int $limit = 240): array { $limit = max(1, min(1500, $limit)); @@ -4530,7 +4550,7 @@ function collect_snapshot(bool $applyFan = true): array $fanSpike = fan_spike_analysis($history, $fan, $system, $processes); $network = network_info(); add_network_usage_log($network); - $networkHistory = network_usage_history(240); + $networkHistory = network_usage_history(480); $shouldLogNotice = !empty($fanSpike['alert_started']) || (!empty($fanSpike['active']) && (bool)setting_value('notice.log_during_alert')); diff --git a/public/assets/app.js b/public/assets/app.js index f4de92a..98fa598 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -161,10 +161,14 @@ rp1Temp: 'RP1 Temp', cpuWatt: 'CPU Watt', networkHistory: 'Network History', - downloadPerSecond: 'Download / s', - uploadPerSecond: 'Upload / s', - rxPacketsPerSecond: 'RX Packets / s', - txPacketsPerSecond: 'TX Packets / s', + wanDownloadPackets: 'WAN Download / RX Packets', + wanUploadPackets: 'WAN Upload / TX Packets', + bridgeDownloadPackets: 'Bridge Download / RX Packets', + bridgeUploadPackets: 'Bridge Upload / TX Packets', + downloadLine: 'Download', + uploadLine: 'Upload', + rxPacketsLine: 'RX Packets', + txPacketsLine: 'TX Packets', remaining: 'Remaining', batteryVoltage: 'Battery Voltage', wifiClients: 'DHCP Clients', @@ -361,10 +365,14 @@ rp1Temp: 'RP1 온도', cpuWatt: 'CPU 전력', networkHistory: '네트워크 이력', - downloadPerSecond: '초당 다운로드 속도', - uploadPerSecond: '초당 업로드 속도', - rxPacketsPerSecond: '초당 RX 패킷', - txPacketsPerSecond: '초당 TX 패킷', + wanDownloadPackets: 'WAN 다운로드 / RX 패킷', + wanUploadPackets: 'WAN 업로드 / TX 패킷', + bridgeDownloadPackets: '브릿지 다운로드 / RX 패킷', + bridgeUploadPackets: '브릿지 업로드 / TX 패킷', + downloadLine: '다운로드', + uploadLine: '업로드', + rxPacketsLine: 'RX 패킷', + txPacketsLine: 'TX 패킷', remaining: '잔여 시간', batteryVoltage: '배터리 전압', wifiClients: 'DHCP 클라이언트', @@ -2439,6 +2447,181 @@ c.update('none'); } + function timeAxis(rows) { + const labels = rows.map(row => String(row.time || '').slice(11, 19)); + const timestamps = rows.map(row => { + const time = String(row.time || '').replace(' ', 'T'); + const parsed = Date.parse(time); + return Number.isFinite(parsed) ? parsed : null; + }); + const latestTimestamp = [...timestamps].reverse().find(value => value !== null) ?? null; + const tickAgeSeconds = index => { + if (latestTimestamp === null || timestamps[index] === null) return null; + return Math.max(0, Math.round((latestTimestamp - timestamps[index]) / 1000)); + }; + const xTickMarkers = new Map(); + [60, 120, 180].forEach(targetAge => { + let bestIndex = -1; + let bestDistance = Number.POSITIVE_INFINITY; + timestamps.forEach((_timestamp, index) => { + const age = tickAgeSeconds(index); + if (age === null) return; + const distance = Math.abs(age - targetAge); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = index; + } + }); + if (bestIndex >= 0 && bestDistance <= 15) { + xTickMarkers.set(bestIndex, t('minuteAgo', { minutes: targetAge / 60 })); + } + }); + const xTickCallback = (_value, index) => xTickMarkers.get(index) || ''; + const xGridColor = context => { + const index = Number(context.index ?? context.tick?.value ?? -1); + return xTickMarkers.has(index) ? 'rgba(203,213,225,.18)' : 'rgba(203,213,225,0)'; + }; + + return { labels, xTickCallback, xGridColor }; + } + + function formatChartNumber(value, suffix = '') { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return '-'; + return `${numeric.toLocaleString(undefined, { maximumFractionDigits: 2 })}${suffix}`; + } + + function networkRowsForIface(rows, iface) { + return rows.filter(row => String(row.iface || '') === iface).slice(-180); + } + + function networkChart(canvasId, rows, speedKey, speedLabel, packetKey, packetLabel, speedColor = '#0ea5e9') { + const canvas = $('#' + canvasId); + if (!canvas || typeof Chart === 'undefined') return; + + const { labels, xTickCallback, xGridColor } = timeAxis(rows); + const speedValues = rows.map(row => { + const value = Number(row[speedKey]); + return Number.isFinite(value) ? value : null; + }); + const packetValues = rows.map(row => { + const value = Number(row[packetKey]); + return Number.isFinite(value) ? value : null; + }); + const speedScale = dynamicScaleForValues(speedValues, { minSpan: 1, padding: 0.3 }); + const packetScale = dynamicScaleForValues(packetValues, { minSpan: 10, padding: 5 }); + const datasets = [ + { + label: speedLabel, + data: speedValues, + yAxisID: 'speed', + borderColor: speedColor, + backgroundColor: 'transparent', + borderWidth: 2.2, + pointRadius: 0, + tension: 0.22, + fill: false, + }, + { + label: packetLabel, + data: packetValues, + yAxisID: 'packets', + borderColor: '#f97316', + backgroundColor: 'transparent', + borderWidth: 2, + borderDash: [5, 4], + pointRadius: 0, + tension: 0.22, + fill: false, + }, + ]; + const options = { + responsive: true, + maintainAspectRatio: false, + animation: false, + interaction: { + intersect: false, + mode: 'index', + }, + plugins: { + legend: { + display: true, + position: 'bottom', + labels: { + boxWidth: 18, + boxHeight: 2, + color: '#8d98aa', + padding: 10, + }, + }, + tooltip: { + displayColors: true, + padding: 8, + callbacks: { + label: item => { + const suffix = item.dataset.yAxisID === 'speed' ? ' Mbit/s' : ' pps'; + return `${item.dataset.label}: ${formatChartNumber(item.parsed.y, suffix)}`; + }, + }, + }, + }, + scales: { + x: { + ticks: { + color: '#64748b', + maxRotation: 0, + autoSkip: false, + callback: xTickCallback, + }, + grid: { + color: xGridColor, + drawTicks: false, + }, + border: { display: false }, + }, + speed: { + min: speedScale.min, + max: speedScale.max, + position: 'left', + ticks: { + color: speedColor, + maxTicksLimit: 4, + callback: value => formatChartNumber(value, ' Mbit/s'), + }, + grid: { color: 'rgba(255,255,255,.045)', drawTicks: false }, + border: { display: false }, + }, + packets: { + min: packetScale.min, + max: packetScale.max, + position: 'right', + ticks: { + color: '#f97316', + maxTicksLimit: 4, + callback: value => formatChartNumber(value, ' pps'), + }, + grid: { drawOnChartArea: false, drawTicks: false }, + border: { display: false }, + }, + }, + }; + + if (!state.charts[canvasId]) { + state.charts[canvasId] = new Chart(canvas, { + type: 'line', + data: { labels, datasets }, + options, + }); + return; + } + + const c = state.charts[canvasId]; + c.data.labels = labels; + c.data.datasets = datasets; + c.options = options; + c.update('none'); + } + function dynamicScaleForValues(values, options = {}) { const floor = Number(options.floor ?? 0); const ceiling = Number.isFinite(Number(options.ceiling)) ? Number(options.ceiling) : null; @@ -2495,11 +2678,14 @@ chart('batteryVoltageChart', 'BATTERYV', rows, 'battery_voltage', '#14b8a6', 'V', dynamicScale(rows, 'battery_voltage', { minSpan: 0.2, padding: 0.06 })); } - 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 })); - chart('netTxPacketsChart', 'NETTXPKT', networkRows, 'tx_pps', '#a855f7', ' pps', dynamicScale(networkRows, 'tx_pps', { minSpan: 10, padding: 5 })); + const networkRows = data.network_history || []; + const wanIface = String(data.network?.default_interface || 'eth0'); + const wanRows = networkRowsForIface(networkRows, wanIface); + const bridgeRows = networkRowsForIface(networkRows, 'br0'); + networkChart('netWanRxChart', wanRows, 'rx_mbps', t('downloadLine'), 'rx_pps', t('rxPacketsLine'), '#0ea5e9'); + networkChart('netWanTxChart', wanRows, 'tx_mbps', t('uploadLine'), 'tx_pps', t('txPacketsLine'), '#22c55e'); + networkChart('netBridgeRxChart', bridgeRows, 'rx_mbps', t('downloadLine'), 'rx_pps', t('rxPacketsLine'), '#0ea5e9'); + networkChart('netBridgeTxChart', bridgeRows, 'tx_mbps', t('uploadLine'), 'tx_pps', t('txPacketsLine'), '#22c55e'); } function resizeChartsSoon() { diff --git a/public/index.php b/public/index.php index b0d1326..7c82e55 100644 --- a/public/index.php +++ b/public/index.php @@ -287,10 +287,10 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}

Network History

-

Download / s

-

Upload / s

-

RX Packets / s

-

TX Packets / s

+

WAN Download / RX Packets

+

WAN Upload / TX Packets

+

Bridge Download / RX Packets

+

Bridge Upload / TX Packets