네트워크 이력 DB 저장 전환
This commit is contained in:
@@ -76,6 +76,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
- `control_state`: 팬 모드와 PWM 상태
|
- `control_state`: 팬 모드와 PWM 상태
|
||||||
- `presence_fan_policy_state`: 외출/귀가 기반 팬 정책의 마지막 확인/적용 상태
|
- `presence_fan_policy_state`: 외출/귀가 기반 팬 정책의 마지막 확인/적용 상태
|
||||||
- `sensor_logs`: 온도, RPM, PWM, load, memory, disk, uptime. 과거 배터리 컬럼은 호환을 위해 유지하지만 UPS 제거 상태에서는 새 배터리값을 기록하지 않습니다.
|
- `sensor_logs`: 온도, RPM, PWM, load, memory, disk, uptime. 과거 배터리 컬럼은 호환을 위해 유지하지만 UPS 제거 상태에서는 새 배터리값을 기록하지 않습니다.
|
||||||
|
- `network_usage_logs`: WAN 기준 누적 byte/packet 카운터와 초당 다운로드/업로드 속도, RX/TX 패킷 이력
|
||||||
- `power_event_states`: 저전압/스로틀링 episode 상태, 최근 지속시간, 최근 이벤트 이력
|
- `power_event_states`: 저전압/스로틀링 episode 상태, 최근 지속시간, 최근 이벤트 이력
|
||||||
- `battery_profile_cache`: 배터리 잔여시간 계산용 장기 프로파일 캐시
|
- `battery_profile_cache`: 배터리 잔여시간 계산용 장기 프로파일 캐시
|
||||||
- `systemd_service_cache`: 사용자 생성 systemd 서비스 목록과 최근 journal 로그의 짧은 TTL 캐시
|
- `systemd_service_cache`: 사용자 생성 systemd 서비스 목록과 최근 journal 로그의 짧은 TTL 캐시
|
||||||
|
|||||||
@@ -589,6 +589,31 @@ function bootstrap_db(): void
|
|||||||
COLLATE=utf8mb4_unicode_ci
|
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("
|
$pdo->exec("
|
||||||
CREATE TABLE IF NOT EXISTS remember_tokens (
|
CREATE TABLE IF NOT EXISTS remember_tokens (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
|||||||
+151
-1
@@ -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
|
function latest_sensor(): array
|
||||||
{
|
{
|
||||||
$stmt = db()->query("
|
$stmt = db()->query("
|
||||||
@@ -4382,6 +4528,9 @@ function collect_snapshot(bool $applyFan = true): array
|
|||||||
|
|
||||||
$processes = process_resource_data((int)setting_value('display.process_limit'));
|
$processes = process_resource_data((int)setting_value('display.process_limit'));
|
||||||
$fanSpike = fan_spike_analysis($history, $fan, $system, $processes);
|
$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'])
|
$shouldLogNotice = !empty($fanSpike['alert_started'])
|
||||||
|| (!empty($fanSpike['active']) && (bool)setting_value('notice.log_during_alert'));
|
|| (!empty($fanSpike['active']) && (bool)setting_value('notice.log_during_alert'));
|
||||||
@@ -4402,7 +4551,8 @@ function collect_snapshot(bool $applyFan = true): array
|
|||||||
'battery' => $battery,
|
'battery' => $battery,
|
||||||
|
|
||||||
'wifi' => wifi_data(),
|
'wifi' => wifi_data(),
|
||||||
'network' => network_info(),
|
'network' => $network,
|
||||||
|
'network_history' => $networkHistory,
|
||||||
'history' => $history,
|
'history' => $history,
|
||||||
'processes' => $processes,
|
'processes' => $processes,
|
||||||
'custom_services' => custom_systemd_services(),
|
'custom_services' => custom_systemd_services(),
|
||||||
|
|||||||
+10
-73
@@ -105,8 +105,6 @@
|
|||||||
settingsOpen: false,
|
settingsOpen: false,
|
||||||
settingsDirty: false,
|
settingsDirty: false,
|
||||||
customServiceScrollHoldUntil: 0,
|
customServiceScrollHoldUntil: 0,
|
||||||
networkLast: null,
|
|
||||||
networkHistory: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const storageKeys = {
|
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) {
|
function renderSystemStatus(data) {
|
||||||
const system = data.system || {};
|
const system = data.system || {};
|
||||||
const disk = system.disk || {};
|
const disk = system.disk || {};
|
||||||
@@ -2379,6 +2309,13 @@
|
|||||||
return xTickMarkers.get(index) || '';
|
return xTickMarkers.get(index) || '';
|
||||||
};
|
};
|
||||||
const xTickCallback = (_value, index) => isXGridTick(index) ? xTickLabel(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 xGridColor = context => {
|
||||||
const index = Number(context.index ?? context.tick?.value ?? -1);
|
const index = Number(context.index ?? context.tick?.value ?? -1);
|
||||||
return isXGridTick(index) ? 'rgba(203,213,225,.18)' : 'rgba(203,213,225,0)';
|
return isXGridTick(index) ? 'rgba(203,213,225,.18)' : 'rgba(203,213,225,0)';
|
||||||
@@ -2481,7 +2418,7 @@
|
|||||||
y: {
|
y: {
|
||||||
min: 0,
|
min: 0,
|
||||||
...scaleOptions,
|
...scaleOptions,
|
||||||
ticks: { color: '#8d98aa', maxTicksLimit: 4 },
|
ticks: { color: '#8d98aa', maxTicksLimit: 4, callback: yTickCallback },
|
||||||
grid: { color: 'rgba(255,255,255,.045)', drawTicks: false },
|
grid: { color: 'rgba(255,255,255,.045)', drawTicks: false },
|
||||||
border: { display: false },
|
border: { display: false },
|
||||||
},
|
},
|
||||||
@@ -2496,6 +2433,7 @@
|
|||||||
c.data.datasets = datasets;
|
c.data.datasets = datasets;
|
||||||
c.options.scales.x.ticks.callback = xTickCallback;
|
c.options.scales.x.ticks.callback = xTickCallback;
|
||||||
c.options.scales.x.grid.color = xGridColor;
|
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.min = scaleOptions.min ?? 0;
|
||||||
c.options.scales.y.max = scaleOptions.max;
|
c.options.scales.y.max = scaleOptions.max;
|
||||||
c.update('none');
|
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 }));
|
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('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('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('netRxPacketsChart', 'NETRXPKT', networkRows, 'rx_pps', '#f97316', ' pps', dynamicScale(networkRows, 'rx_pps', { minSpan: 10, padding: 5 }));
|
||||||
@@ -2582,7 +2520,6 @@
|
|||||||
renderSettings(data.settings);
|
renderSettings(data.settings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pushNetworkSample(data);
|
|
||||||
renderTop(data);
|
renderTop(data);
|
||||||
renderSystemStatus(data);
|
renderSystemStatus(data);
|
||||||
renderWifi(data);
|
renderWifi(data);
|
||||||
|
|||||||
Reference in New Issue
Block a user