터치 패널 정리와 재부팅 원인 기록 보강
This commit is contained in:
1 parent
64510e8fe5
commit
11f27e5347
9 files changed
+488
-358
No files matched your search
@@ -105,7 +105,11 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
|
|
||||||
## 로그 보존
|
## 로그 보존
|
||||||
|
|
||||||
커널 패닉이나 강제 재부팅 분석을 위해 `systemd-journald`는 `/var/log/journal`에 영구 저장합니다. `dtoverlay=ramoops` 기반 pstore는 부팅 직후 `control-pstore-collect.service`가 `/var/log/pstore`로 복사합니다. 현재 pstore에 남은 크래시 조각이 없으면 이 서비스는 조건 불충족으로 `inactive`일 수 있으며 정상입니다.
|
커널 패닉이나 강제 재부팅 분석을 위해 `systemd-journald`는 `/var/log/journal`에 영구 저장합니다. `dtoverlay=ramoops` 기반 pstore는 기본 `systemd-pstore.service`가 먼저 `/var/lib/systemd/pstore`에 보존하고, `control-pstore-collect.service`가 내용 해시 기준으로 `/var/log/pstore`에 중복 없이 복사합니다.
|
||||||
|
|
||||||
|
`control-boot-capture.service`는 부팅마다 한 번 실행되어 이전 부팅의 종료 흔적, journal 마지막 시각, 마지막 5,000개 journal에서 확인한 OOM/커널 패닉과 최근 커널 경고의 저장장치 오류 개수, 현재 부팅에서 발견한 pstore, 하드웨어 워치독 설정, `get_rsts` 원문, SSD unsafe shutdown 누적값과 재부팅 직전 `sensor_logs` 120개를 `/var/log/control/boot-captures/<boot-id>/`에 저장합니다. 요약은 `/var/log/control/boot-events.jsonl`에 한 줄씩 누적하며 같은 boot ID는 중복 기록하지 않습니다. `watchdog_likely`는 정상 종료·panic 흔적이 없고 warm-reset pstore와 런타임 워치독이 함께 확인된 경우의 추정 판정입니다.
|
||||||
|
|
||||||
|
CPU 사용률, I/O wait, 디스크 처리량, 스왑 및 실행 대기열의 장기 기록은 `sysstat`이 담당합니다. `/etc/default/sysstat`의 `ENABLED=true`와 `systemd/sysstat-collect.timer.d/10-control.conf`를 적용하면 1분 간격으로 `/var/log/sysstat/saDD`에 저장됩니다. 기존 `sensor_logs`의 1초 간격 온도, 팬, load, 메모리 기록과 함께 재부팅 직전 상태를 확인합니다.
|
||||||
|
|
||||||
## 시놀로지 팬 가드
|
## 시놀로지 팬 가드
|
||||||
|
|
||||||
@@ -155,17 +159,17 @@ cd /var/www/control
|
|||||||
/usr/bin/python3 touch_panel/panel.py
|
/usr/bin/python3 touch_panel/panel.py
|
||||||
```
|
```
|
||||||
|
|
||||||
패널은 Control PHP/API/DB snapshot을 호출하지 않는 standalone 방식입니다. Python이 `/sys/class/net`, `/proc/diskstats`, `/sys/class/thermal`, 팬 hwmon 파일을 직접 읽습니다. 화면은 왼쪽 `RASPBERRY` 18칸, 오른쪽 `SYNOLOGY` 18칸 카드로 나눕니다. 하단에는 좌우 6칸 폭을 한 번에 쓰는 긴 `PC` 버튼을 둡니다.
|
패널은 Control PHP/API/DB snapshot을 호출하지 않는 standalone 방식입니다. Python이 `/sys/class/net`, `/proc/diskstats`, `/sys/class/thermal`, 팬 hwmon 파일을 직접 읽습니다. 화면 왼쪽에는 별도 섹션 제목 없이 상태 카드 21칸을 두고, 오른쪽에는 온도/RPM과 WAN 트래픽 그래프를 표시합니다. `PC` 버튼은 왼쪽 상태 패널 아래에서 3칸 전체 폭을 사용합니다.
|
||||||
|
|
||||||
`RASPBERRY` 카드는 `SSD I/O`, `TEMP`, `FAN`, `THROT`, `CPU`, `IRQ`, `CTX`, `FORK`, `LOCAL`, `UPLINK`, `ETH1`, `5G`, `2.4G`, `SOFTIRQ`, `TCP`, `UDP`, `VM`, `RUNQ`를 표시합니다. 네트워크와 SSD I/O 점멸은 100ms 샘플에서 직전 카운터보다 증가했는지를 보고, CPU/IRQ/CTX/FORK/SOFTIRQ/TCP/UDP/VM/RUNQ 카드는 `/proc/stat`, `/proc/net/snmp`, `/proc/vmstat`의 누적 카운터 증가량으로 점멸합니다. CPU 온도/SSD 온도와 팬 RPM은 500ms 간격으로 표시합니다.
|
라즈베리파이 상태 카드는 `SSD I/O`, `FAN`, `THROT`, `CPU`, `IRQ`, `CTX`, `FORK`, `LOCAL`, `UPLINK`, `ETH1`, `5G`, `2.4G`, `SOFTIRQ`, `TCP`, `UDP`, `VM`, `RUNQ`, `LOAD`, `MEM`, `ROOT`를 표시합니다. 네트워크와 SSD I/O 점멸은 100ms 샘플에서 직전 카운터보다 증가했는지를 보고, CPU/IRQ/CTX/FORK/SOFTIRQ/TCP/UDP/VM/RUNQ 카드는 `/proc/stat`, `/proc/net/snmp`, `/proc/vmstat`의 누적 카운터 증가량으로 점멸합니다. `LOAD`, `MEM`, `ROOT`는 1초마다 갱신합니다. 오른쪽 온도/RPM과 WAN 그래프는 모두 100ms 간격으로 200포인트를 기록해 최근 20초를 같은 시간축으로 표시합니다. 온도와 RPM 센서 자체는 500ms마다 읽고 그 최신값을 그래프에 사용합니다.
|
||||||
|
|
||||||
`SYNOLOGY` 카드는 `PING`, `TEMP`, `FAN`, `GUARD`, `CPU`, `IRQ`, `CTX`, `FORK`, `SOFTIRQ`, `TCP`, `UDP`, `VM`, `LOCAL`, `UPLINK`, `RUNQ`, `SATA1 I/O`, `SATA2 I/O`, `SATA4 I/O`를 표시합니다. 시놀로지는 SSH를 유지한 채 100ms마다 네트워크, 디스크 sector I/O, CPU/IRQ/CTX/FORK/SOFTIRQ/TCP/UDP/VM/RUNQ 카운터를 스트림으로 가져옵니다. CPU 온도, 팬 RPM, fan guard 상태도 같은 SSH 스트림 안에서 약 5초마다 가져오며 별도 SSH 연결은 만들지 않습니다. SATA SMART/온도 직접 조회는 응답 지연 위험 때문에 대시보드에서 실행하지 않고, SATA I/O 카드에는 `조회 중지`를 표시합니다.
|
왼쪽 `PING` 카드는 SSH나 원격 센서 조회를 사용하지 않고 `chaegeon.com`에 장기 실행 ping을 보내 지연시간만 표시합니다.
|
||||||
|
|
||||||
온도 카드는 80도 이상부터 빨간 표시등을 500ms 간격으로 점멸하고, 팬 카드는 RPM 0 또는 7000 초과를 빨강, 4500 초과를 주황으로 표시합니다. 시놀로지 `PING`은 장기 실행 `ping -i 0.5` 출력 파싱으로 읽고, 15ms 이하 초록, 30ms 이하 주황, 50ms 이하 빨강, 50ms 초과 빨강 500ms 점멸입니다. `RPI THROT`은 정상 초록, 부팅 후 이력 주황, 현재 언더볼트 빨강, 현재 스로틀 빨강 500ms 점멸, 현재 언더볼트와 스로틀 동시 발생 빨강 100ms 점멸로 표시합니다. `SYNO GUARD`는 `synology-fan-guard.service`가 active이고 DSM 자동제어면 초록 `AUTO`, 고온으로 PWM 최대 강제 중이면 주황 `FULL`, 서비스 실패나 팬 RPM 0이면 빨강 `FAIL`로 표시합니다.
|
팬 카드는 RPM 0 또는 7000 초과를 빨강, 4500 초과를 주황으로 표시합니다. 시놀로지 `PING`은 장기 실행 `ping -i 0.5` 출력 파싱으로 읽고, 15ms 이하 초록, 30ms 이하 주황, 50ms 이하 빨강, 50ms 초과 빨강 500ms 점멸입니다. `RPI THROT`은 정상 초록, 부팅 후 이력 주황, 현재 언더볼트 빨강, 현재 스로틀 빨강 500ms 점멸, 현재 언더볼트와 스로틀 동시 발생 빨강 100ms 점멸로 표시합니다.
|
||||||
|
|
||||||
각 타일에는 글자 아래에 세 개의 작은 표시등을 두며 읽기/업로드는 초록, 쓰기/다운로드는 주황, 오류/드롭 증가는 빨강으로 표시합니다. 활동 표시등은 이벤트 감지 후 120ms 동안 유지하고, 오류 표시등은 2초 동안 유지해 너무 짧은 이벤트도 눈에 보이게 합니다. 네트워크 카드 오른쪽 값은 항상 `D n`으로 드롭 누적을 표시합니다. `LOCAL`은 `lo`, `UPLINK`는 기본 라우트 인터페이스 기준입니다. 라즈베리파이 `ETH1`은 컴퓨터와 연결된 물리 포트 단독 카운터이며 `br0`는 합산하지 않습니다. `SSD I/O`의 값은 `/run/control-rpi-disk-temp` 캐시 파일의 `/dev/sda` Samsung T7 디스크 자체 온도입니다. 캐시 파일은 root로 실행되는 `control-rpi-disk-temp.service`가 몇 초마다 `smartctl -d sntasmedia`로 갱신하므로 GUI 패널은 sudo를 반복 실행하지 않습니다.
|
각 타일에는 글자 아래에 세 개의 작은 표시등을 두며 읽기/업로드는 초록, 쓰기/다운로드는 주황, 오류/드롭 증가는 빨강으로 표시합니다. 활동 표시등은 이벤트 감지 후 120ms 동안 유지하고, 오류 표시등은 2초 동안 유지해 너무 짧은 이벤트도 눈에 보이게 합니다. 네트워크 카드 오른쪽 값은 항상 `D n`으로 드롭 누적을 표시합니다. `LOCAL`은 `lo`, `UPLINK`는 기본 라우트 인터페이스 기준입니다. 라즈베리파이 `ETH1`은 컴퓨터와 연결된 물리 포트 단독 카운터이며 `br0`는 합산하지 않습니다. `SSD I/O`의 값은 `/run/control-rpi-disk-temp` 캐시 파일의 `/dev/sda` Samsung T7 디스크 자체 온도입니다. 캐시 파일은 root로 실행되는 `control-rpi-disk-temp.service`가 몇 초마다 `smartctl -d sntasmedia`로 갱신하므로 GUI 패널은 sudo를 반복 실행하지 않습니다.
|
||||||
|
|
||||||
하단 `PC` 버튼은 `192.168.50.112`에 1초마다 ping을 보내 켜짐 여부를 표시하고, 꺼져 있을 때 누르면 DHCP lease에서 해당 IP의 현재 MAC을 찾아 WOL magic packet을 보냅니다. 주황은 WOL 후 최대 1분 동안 컴퓨터 응답 대기 표시, 빨강은 magic packet 전송 실패 시 5초 또는 1분 대기 후 응답 실패 시 1분 표시입니다.
|
하단 `PC` 버튼은 현재 PC MAC `00:00:00:00:00:d7`을 기준으로 DHCP lease에서 IP를 찾아 1초마다 ping을 보내고, lease를 찾지 못하면 `192.168.50.34`를 사용합니다. 꺼져 있을 때 누르면 같은 MAC으로 WOL magic packet을 보냅니다. 주황은 WOL 후 최대 1분 동안 컴퓨터 응답 대기 표시, 빨강은 magic packet 전송 실패 시 5초 또는 1분 대기 후 응답 실패 시 1분 표시입니다.
|
||||||
|
|
||||||
GUI 자동 실행은 데스크톱 autostart가 1차 기준입니다.
|
GUI 자동 실행은 데스크톱 autostart가 1차 기준입니다.
|
||||||
|
|
||||||
@@ -235,7 +239,7 @@ control-wifi-observe.service
|
|||||||
3. 대시보드는 `status` snapshot을 렌더링합니다.
|
3. 대시보드는 `status` snapshot을 렌더링합니다.
|
||||||
4. WebSocket 연결이 성공하면 `status` 메시지로 갱신하고, 실패하면 HTTP fallback을 사용합니다.
|
4. WebSocket 연결이 성공하면 `status` 메시지로 갱신하고, 실패하면 HTTP fallback을 사용합니다.
|
||||||
5. 팬 조작은 상태 저장, 정책 적용, 로그 저장 순서로 처리합니다.
|
5. 팬 조작은 상태 저장, 정책 적용, 로그 저장 순서로 처리합니다.
|
||||||
6. WiFi client 목록은 `iw station dump`와 dnsmasq lease를 조합하고, LAN client는 `bridge fdb show br br0`의 USB LAN 포트 MAC을 dnsmasq lease와 매칭합니다. 5G 또는 LAN 연결 시간이 비어 있으면 MAC 기준 최초 감지 시간을 DB에 저장해 경과 시간을 계산합니다. LAN client의 속도 칸은 무선 송수신 속도 대신 USB LAN 인터페이스의 현재 협상 링크 속도를 표시합니다.
|
6. WiFi client 목록은 `iw station dump`와 dnsmasq lease를 조합하고, LAN client는 `bridge fdb show br br0`의 USB LAN 포트 MAC을 dnsmasq lease와 매칭합니다. `eth1`은 PC 직결 포트이므로 오프라인 WOL 행은 lease가 남아 있는 MAC 중 가장 최근에 관측한 하나만 유지합니다. 5G 또는 LAN 연결 시간이 비어 있으면 MAC 기준 최초 감지 시간을 DB에 저장해 경과 시간을 계산합니다. LAN client의 속도 칸은 무선 송수신 속도 대신 USB LAN 인터페이스의 현재 협상 링크 속도를 표시합니다.
|
||||||
7. WiFi hostname이 비어 있으면 MAC 기반 임시명을 표시하고, 수동 저장한 MAC별 이름은 DB에서 우선 적용합니다.
|
7. WiFi hostname이 비어 있으면 MAC 기반 임시명을 표시하고, 수동 저장한 MAC별 이름은 DB에서 우선 적용합니다.
|
||||||
8. WiFi 신호/송신 속도/수신 속도 값이 비어 있으면 실제로 존재하는 다른 무선 지표와 대역별 기준으로 값을 채웁니다.
|
8. WiFi 신호/송신 속도/수신 속도 값이 비어 있으면 실제로 존재하는 다른 무선 지표와 대역별 기준으로 값을 채웁니다.
|
||||||
9. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G/LAN 관측 세션을 갱신합니다.
|
9. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G/LAN 관측 세션을 갱신합니다.
|
||||||
|
|||||||
Executable
+238
@@ -0,0 +1,238 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -u
|
||||||
|
|
||||||
|
state_dir=/var/lib/control
|
||||||
|
log_dir=/var/log/control
|
||||||
|
capture_root=$log_dir/boot-captures
|
||||||
|
event_log=$log_dir/boot-events.jsonl
|
||||||
|
latest_event=$log_dir/boot-events.latest.json
|
||||||
|
lock_file=/run/lock/control-boot-capture.lock
|
||||||
|
boot_id=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [[ ! "$boot_id" =~ ^[0-9a-f-]{36}$ ]]; then
|
||||||
|
logger -t control-boot-capture "cannot read current boot id"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$state_dir" "$capture_root"
|
||||||
|
chmod 0750 "$state_dir" "$log_dir" "$capture_root"
|
||||||
|
exec 9>"$lock_file"
|
||||||
|
flock -x 9
|
||||||
|
|
||||||
|
last_boot_id=$(cat "$state_dir/last-captured-boot-id" 2>/dev/null || true)
|
||||||
|
if [ "$last_boot_id" = "$boot_id" ] && [ "${CONTROL_BOOT_CAPTURE_REFRESH:-0}" != "1" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
captured_at=$(date --iso-8601=seconds)
|
||||||
|
booted_at=$(uptime -s 2>/dev/null || true)
|
||||||
|
capture_dir=$capture_root/$boot_id
|
||||||
|
mkdir -p "$capture_dir"
|
||||||
|
chmod 0750 "$capture_dir"
|
||||||
|
|
||||||
|
previous_boot_id=$(journalctl --list-boots --no-pager 2>/dev/null | awk '$1 == -1 {print $2; exit}')
|
||||||
|
previous_last_json=$(journalctl -b -1 -n 1 -o json --no-pager 2>/dev/null || true)
|
||||||
|
previous_last_at=$(printf '%s\n' "$previous_last_json" | python3 -c '
|
||||||
|
import datetime, json, sys
|
||||||
|
try:
|
||||||
|
row = json.load(sys.stdin)
|
||||||
|
stamp = int(row.get("__REALTIME_TIMESTAMP", 0)) / 1_000_000
|
||||||
|
print(datetime.datetime.fromtimestamp(stamp).astimezone().isoformat(timespec="seconds") if stamp else "")
|
||||||
|
except Exception:
|
||||||
|
print("")
|
||||||
|
' 2>/dev/null)
|
||||||
|
|
||||||
|
journalctl -b -1 -n 5000 -o short-iso-precise --no-pager \
|
||||||
|
>"$capture_dir/previous-journal-tail.log" 2>/dev/null || true
|
||||||
|
journalctl -b -1 -k -p warning..alert -n 2000 -o short-iso-precise --no-pager \
|
||||||
|
>"$capture_dir/previous-kernel-warnings.log" 2>/dev/null || true
|
||||||
|
|
||||||
|
shutdown_lines=$(grep -ciE 'systemd-shutdown|Reached target (System Reboot|Shutdown|Power-Off)|reboot: Restarting system|Powering off' \
|
||||||
|
"$capture_dir/previous-journal-tail.log" || true)
|
||||||
|
panic_lines=$(grep -ciE 'Kernel panic|not syncing|Oops:|BUG:|soft lockup|hard LOCKUP|hung task' \
|
||||||
|
"$capture_dir/previous-kernel-warnings.log" || true)
|
||||||
|
oom_lines=$(grep -ciE 'Out of memory|oom-kill|Killed process .* total-vm' \
|
||||||
|
"$capture_dir/previous-journal-tail.log" || true)
|
||||||
|
storage_error_lines=$(grep -ciE 'I/O error|EXT4-fs error|Buffer I/O error|blk_update_request|uas.*reset|sd .*timing out' \
|
||||||
|
"$capture_dir/previous-kernel-warnings.log" || true)
|
||||||
|
|
||||||
|
pstore_source=/var/lib/systemd/pstore
|
||||||
|
pstore_count=0
|
||||||
|
pstore_panic_lines=0
|
||||||
|
declare -a pstore_files=()
|
||||||
|
if [ -d "$pstore_source" ]; then
|
||||||
|
while IFS= read -r pstore_name; do
|
||||||
|
pstore_name=$(basename "$pstore_name")
|
||||||
|
[ -f "$pstore_source/$pstore_name" ] && pstore_files+=("$pstore_source/$pstore_name")
|
||||||
|
done < <(journalctl -b 0 -u systemd-pstore.service -o cat --no-pager 2>/dev/null \
|
||||||
|
| sed -n 's/^PStore \([^ ]*\) moved to .*$/\1/p')
|
||||||
|
|
||||||
|
if [ "${#pstore_files[@]}" -eq 0 ] && [ -n "$booted_at" ]; then
|
||||||
|
pstore_cutoff=$(date -d "$booted_at 10 minutes ago" --iso-8601=seconds 2>/dev/null || true)
|
||||||
|
if [ -n "$pstore_cutoff" ]; then
|
||||||
|
while IFS= read -r -d '' pstore_file; do
|
||||||
|
pstore_files+=("$pstore_file")
|
||||||
|
done < <(find "$pstore_source" -maxdepth 1 -type f -newermt "$pstore_cutoff" -print0 2>/dev/null)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
for pstore_file in "${pstore_files[@]}"; do
|
||||||
|
pstore_count=$((pstore_count + 1))
|
||||||
|
grep -ciE 'Kernel panic|not syncing|Oops:|BUG:' "$pstore_file" \
|
||||||
|
>"$capture_dir/.pstore-panic-count" 2>/dev/null || true
|
||||||
|
file_panic_lines=$(cat "$capture_dir/.pstore-panic-count" 2>/dev/null || printf '0')
|
||||||
|
pstore_panic_lines=$((pstore_panic_lines + file_panic_lines))
|
||||||
|
cp -a "$pstore_file" "$capture_dir/" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
rm -f "$capture_dir/.pstore-panic-count"
|
||||||
|
|
||||||
|
runtime_watchdog=$(systemctl show --property=RuntimeWatchdogUSec --value 2>/dev/null || true)
|
||||||
|
watchdog_bootstatus=$(cat /sys/class/watchdog/watchdog0/bootstatus 2>/dev/null || true)
|
||||||
|
reset_status=$(vcgencmd get_rsts 2>/dev/null | sed 's/^get_rsts=//' || true)
|
||||||
|
unsafe_shutdowns=$(smartctl -a -d sntasmedia /dev/sda 2>/dev/null \
|
||||||
|
| awk -F: '/Unsafe Shutdowns/ {gsub(/[ ,]/, "", $2); print $2; exit}')
|
||||||
|
|
||||||
|
classification=unclean_reset
|
||||||
|
if [ "$shutdown_lines" -gt 0 ]; then
|
||||||
|
classification=clean_shutdown
|
||||||
|
elif [ "$pstore_panic_lines" -gt 0 ] || [ "$panic_lines" -gt 0 ]; then
|
||||||
|
classification=kernel_panic
|
||||||
|
elif [ "$pstore_count" -gt 0 ] && [ -n "$runtime_watchdog" ] && [ "$runtime_watchdog" != "0" ]; then
|
||||||
|
classification=watchdog_likely
|
||||||
|
fi
|
||||||
|
|
||||||
|
gap_seconds=''
|
||||||
|
if [ -n "$booted_at" ] && [ -n "$previous_last_at" ]; then
|
||||||
|
boot_epoch=$(date -d "$booted_at" +%s 2>/dev/null || true)
|
||||||
|
previous_epoch=$(date -d "$previous_last_at" +%s 2>/dev/null || true)
|
||||||
|
if [ -n "$boot_epoch" ] && [ -n "$previous_epoch" ]; then
|
||||||
|
gap_seconds=$((boot_epoch - previous_epoch))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
last_sensor_at=''
|
||||||
|
last_sensor_summary=''
|
||||||
|
if command -v mysql >/dev/null 2>&1 && [ -n "$booted_at" ]; then
|
||||||
|
mysql --batch --skip-column-names fanpanel -e "
|
||||||
|
SELECT recorded_at,cpu_temp_c,fan_rpm,cpu_load_1,cpu_load_5,cpu_load_15,
|
||||||
|
mem_total_mb,mem_used_mb,mem_free_mb,disk_used_kb,uptime_seconds
|
||||||
|
FROM sensor_logs
|
||||||
|
WHERE recorded_at < '$booted_at'
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 120
|
||||||
|
" >"$capture_dir/previous-sensors.tsv" 2>/dev/null || true
|
||||||
|
last_sensor_summary=$(head -n 1 "$capture_dir/previous-sensors.tsv" 2>/dev/null || true)
|
||||||
|
last_sensor_at=$(printf '%s' "$last_sensor_summary" | cut -f1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
printf 'captured_at=%s\n' "$captured_at"
|
||||||
|
printf 'boot_id=%s\n' "$boot_id"
|
||||||
|
printf 'booted_at=%s\n' "$booted_at"
|
||||||
|
printf 'previous_boot_id=%s\n' "$previous_boot_id"
|
||||||
|
printf 'previous_last_at=%s\n' "$previous_last_at"
|
||||||
|
printf 'gap_seconds=%s\n' "$gap_seconds"
|
||||||
|
printf 'classification=%s\n' "$classification"
|
||||||
|
printf 'shutdown_lines=%s\n' "$shutdown_lines"
|
||||||
|
printf 'panic_lines=%s\n' "$panic_lines"
|
||||||
|
printf 'oom_lines=%s\n' "$oom_lines"
|
||||||
|
printf 'storage_error_lines=%s\n' "$storage_error_lines"
|
||||||
|
printf 'pstore_count=%s\n' "$pstore_count"
|
||||||
|
printf 'pstore_panic_lines=%s\n' "$pstore_panic_lines"
|
||||||
|
printf 'runtime_watchdog=%s\n' "$runtime_watchdog"
|
||||||
|
printf 'watchdog_bootstatus=%s\n' "$watchdog_bootstatus"
|
||||||
|
printf 'reset_status=%s\n' "$reset_status"
|
||||||
|
printf 'unsafe_shutdowns=%s\n' "$unsafe_shutdowns"
|
||||||
|
printf 'last_sensor_at=%s\n' "$last_sensor_at"
|
||||||
|
printf 'last_sensor=%s\n' "$last_sensor_summary"
|
||||||
|
} >"$capture_dir/summary.env"
|
||||||
|
|
||||||
|
CAPTURED_AT="$captured_at" \
|
||||||
|
BOOT_ID="$boot_id" \
|
||||||
|
BOOTED_AT="$booted_at" \
|
||||||
|
PREVIOUS_BOOT_ID="$previous_boot_id" \
|
||||||
|
PREVIOUS_LAST_AT="$previous_last_at" \
|
||||||
|
GAP_SECONDS="$gap_seconds" \
|
||||||
|
CLASSIFICATION="$classification" \
|
||||||
|
SHUTDOWN_LINES="$shutdown_lines" \
|
||||||
|
PANIC_LINES="$panic_lines" \
|
||||||
|
OOM_LINES="$oom_lines" \
|
||||||
|
STORAGE_ERROR_LINES="$storage_error_lines" \
|
||||||
|
PSTORE_COUNT="$pstore_count" \
|
||||||
|
PSTORE_PANIC_LINES="$pstore_panic_lines" \
|
||||||
|
RUNTIME_WATCHDOG="$runtime_watchdog" \
|
||||||
|
WATCHDOG_BOOTSTATUS="$watchdog_bootstatus" \
|
||||||
|
RESET_STATUS="$reset_status" \
|
||||||
|
UNSAFE_SHUTDOWNS="$unsafe_shutdowns" \
|
||||||
|
LAST_SENSOR_AT="$last_sensor_at" \
|
||||||
|
CAPTURE_DIR="$capture_dir" \
|
||||||
|
EVENT_LOG="$event_log" \
|
||||||
|
LATEST_EVENT="$latest_event" \
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def text(name):
|
||||||
|
return os.environ.get(name, "")
|
||||||
|
|
||||||
|
def integer(name):
|
||||||
|
value = text(name)
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
row = {
|
||||||
|
"captured_at": text("CAPTURED_AT"),
|
||||||
|
"boot_id": text("BOOT_ID"),
|
||||||
|
"booted_at": text("BOOTED_AT"),
|
||||||
|
"previous_boot_id": text("PREVIOUS_BOOT_ID"),
|
||||||
|
"previous_last_at": text("PREVIOUS_LAST_AT"),
|
||||||
|
"gap_seconds": integer("GAP_SECONDS"),
|
||||||
|
"classification": text("CLASSIFICATION"),
|
||||||
|
"shutdown_lines": integer("SHUTDOWN_LINES"),
|
||||||
|
"panic_lines": integer("PANIC_LINES"),
|
||||||
|
"oom_lines": integer("OOM_LINES"),
|
||||||
|
"storage_error_lines": integer("STORAGE_ERROR_LINES"),
|
||||||
|
"pstore_count": integer("PSTORE_COUNT"),
|
||||||
|
"pstore_panic_lines": integer("PSTORE_PANIC_LINES"),
|
||||||
|
"runtime_watchdog": text("RUNTIME_WATCHDOG"),
|
||||||
|
"watchdog_bootstatus": text("WATCHDOG_BOOTSTATUS"),
|
||||||
|
"reset_status": text("RESET_STATUS"),
|
||||||
|
"unsafe_shutdowns": integer("UNSAFE_SHUTDOWNS"),
|
||||||
|
"last_sensor_at": text("LAST_SENSOR_AT"),
|
||||||
|
"capture_dir": text("CAPTURE_DIR"),
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded = json.dumps(row, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
event_log = Path(text("EVENT_LOG"))
|
||||||
|
latest_event = Path(text("LATEST_EVENT"))
|
||||||
|
existing = event_log.read_text(encoding="utf-8").splitlines() if event_log.exists() else []
|
||||||
|
updated = []
|
||||||
|
replaced = False
|
||||||
|
for line in existing:
|
||||||
|
try:
|
||||||
|
old = json.loads(line)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
updated.append(line)
|
||||||
|
continue
|
||||||
|
if old.get("boot_id") == row["boot_id"]:
|
||||||
|
if not replaced:
|
||||||
|
updated.append(encoded)
|
||||||
|
replaced = True
|
||||||
|
continue
|
||||||
|
updated.append(line)
|
||||||
|
if not replaced:
|
||||||
|
updated.append(encoded)
|
||||||
|
event_log.write_text("\n".join(updated) + "\n", encoding="utf-8")
|
||||||
|
temporary = latest_event.with_suffix(".tmp")
|
||||||
|
temporary.write_text(encoded + "\n", encoding="utf-8")
|
||||||
|
temporary.replace(latest_event)
|
||||||
|
PY
|
||||||
|
|
||||||
|
chmod 0640 "$event_log" "$latest_event" "$capture_dir"/* 2>/dev/null || true
|
||||||
|
printf '%s\n' "$boot_id" >"$state_dir/last-captured-boot-id"
|
||||||
|
chmod 0640 "$state_dir/last-captured-boot-id"
|
||||||
|
logger -t control-boot-capture "recorded $classification for boot $boot_id"
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
src=/var/lib/systemd/pstore
|
||||||
|
dst=/var/log/pstore
|
||||||
|
|
||||||
|
mkdir -p "$dst"
|
||||||
|
chmod 0750 "$dst"
|
||||||
|
|
||||||
|
found=0
|
||||||
|
if [ -d "$src" ]; then
|
||||||
|
for source_file in "$src"/*; do
|
||||||
|
[ -f "$source_file" ] || continue
|
||||||
|
found=1
|
||||||
|
base=$(basename "$source_file")
|
||||||
|
digest=$(sha256sum "$source_file" | awk '{print substr($1, 1, 16)}')
|
||||||
|
target="$dst/$digest-$base"
|
||||||
|
if [ ! -e "$target" ]; then
|
||||||
|
cp -a "$source_file" "$target"
|
||||||
|
chmod 0640 "$target" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$found" -eq 1 ]; then
|
||||||
|
logger -t control-pstore-collect "archived systemd pstore files in $dst"
|
||||||
|
fi
|
||||||
+44
-10
@@ -191,18 +191,38 @@ function command_first_line(array $cmd, bool $root = false, int $timeout = 3): s
|
|||||||
function log_retention_status(): array
|
function log_retention_status(): array
|
||||||
{
|
{
|
||||||
$journalDir = '/var/log/journal';
|
$journalDir = '/var/log/journal';
|
||||||
$journalDisk = command_first_line(['/usr/bin/journalctl', '--disk-usage', '--no-pager'], false, 3);
|
$journalDisk = command_first_line(['/usr/bin/journalctl', '--disk-usage', '--no-pager'], true, 3);
|
||||||
$journalBoots = command_first_line(['/usr/bin/bash', '-lc', 'journalctl --list-boots --no-pager 2>/dev/null | wc -l'], false, 3);
|
$journalBootResult = sh(['/usr/bin/journalctl', '--list-boots', '--no-pager'], true, 3);
|
||||||
|
$journalBootLines = (int)$journalBootResult['code'] === 0
|
||||||
|
? array_values(array_filter(
|
||||||
|
preg_split('/\R/', trim((string)$journalBootResult['out'])) ?: [],
|
||||||
|
static fn($line): bool => preg_match('/^\s*-?\d+\s+[0-9a-f]{32}\s+/i', (string)$line) === 1
|
||||||
|
))
|
||||||
|
: [];
|
||||||
$pstoreActive = command_first_line(['/usr/bin/systemctl', 'is-active', 'control-pstore-collect.service'], false, 3) ?: 'inactive';
|
$pstoreActive = command_first_line(['/usr/bin/systemctl', 'is-active', 'control-pstore-collect.service'], false, 3) ?: 'inactive';
|
||||||
$savedCrashFiles = glob('/var/log/pstore/*') ?: [];
|
$controlCrashFiles = glob('/var/log/pstore/*') ?: [];
|
||||||
|
$systemdCrashFiles = glob('/var/lib/systemd/pstore/*') ?: [];
|
||||||
|
$savedCrashFiles = $controlCrashFiles !== [] ? $controlCrashFiles : $systemdCrashFiles;
|
||||||
$pstoreFiles = glob('/sys/fs/pstore/*') ?: [];
|
$pstoreFiles = glob('/sys/fs/pstore/*') ?: [];
|
||||||
|
$bootEventPath = '/var/log/control/boot-events.jsonl';
|
||||||
|
$bootEventLines = is_readable($bootEventPath)
|
||||||
|
? (file($bootEventPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [])
|
||||||
|
: [];
|
||||||
|
$latestBootEvent = [];
|
||||||
|
if ($bootEventLines !== []) {
|
||||||
|
$decoded = json_decode((string)end($bootEventLines), true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$latestBootEvent = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$bootCaptureActive = $latestBootEvent !== [] ? 'active' : 'inactive';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'journal' => [
|
'journal' => [
|
||||||
'path' => $journalDir,
|
'path' => $journalDir,
|
||||||
'persistent' => is_dir($journalDir),
|
'persistent' => is_dir($journalDir),
|
||||||
'disk_usage' => $journalDisk ?: '-',
|
'disk_usage' => $journalDisk ?: '-',
|
||||||
'boot_count' => is_numeric(trim($journalBoots)) ? (int)trim($journalBoots) : null,
|
'boot_count' => count($journalBootLines),
|
||||||
'ok' => is_dir($journalDir),
|
'ok' => is_dir($journalDir),
|
||||||
],
|
],
|
||||||
'pstore' => [
|
'pstore' => [
|
||||||
@@ -210,8 +230,17 @@ function log_retention_status(): array
|
|||||||
'collector_active' => $pstoreActive,
|
'collector_active' => $pstoreActive,
|
||||||
'pending_files' => count($pstoreFiles),
|
'pending_files' => count($pstoreFiles),
|
||||||
'saved_files' => count($savedCrashFiles),
|
'saved_files' => count($savedCrashFiles),
|
||||||
|
'systemd_saved_files' => count($systemdCrashFiles),
|
||||||
'saved_path' => '/var/log/pstore',
|
'saved_path' => '/var/log/pstore',
|
||||||
'ok' => is_dir('/sys/fs/pstore'),
|
'systemd_path' => '/var/lib/systemd/pstore',
|
||||||
|
'ok' => is_dir('/sys/fs/pstore') && is_dir('/var/lib/systemd/pstore'),
|
||||||
|
],
|
||||||
|
'boot_capture' => [
|
||||||
|
'active' => $bootCaptureActive,
|
||||||
|
'path' => $bootEventPath,
|
||||||
|
'count' => count($bootEventLines),
|
||||||
|
'latest' => $latestBootEvent,
|
||||||
|
'ok' => $bootCaptureActive === 'active' && is_readable($bootEventPath),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -3408,14 +3437,17 @@ function remember_lan_wol_clients(array $rows): void
|
|||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$mac = strtolower((string)($row['mac'] ?? ''));
|
$mac = strtolower((string)($row['mac'] ?? ''));
|
||||||
if (!wifi_valid_mac($mac) || (string)($row['presence'] ?? '') !== 'fdb') {
|
$ip = (string)($row['ip'] ?? '');
|
||||||
|
if (!wifi_valid_mac($mac)
|
||||||
|
|| (string)($row['presence'] ?? '') !== 'fdb'
|
||||||
|
|| filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
':mac' => $mac,
|
':mac' => $mac,
|
||||||
':iface' => (string)($row['iface'] ?? 'eth1'),
|
':iface' => (string)($row['iface'] ?? 'eth1'),
|
||||||
':last_ip' => ($row['ip'] ?? 'N/A') === 'N/A' ? null : (string)$row['ip'],
|
':last_ip' => $ip,
|
||||||
':hostname' => ($row['hostname'] ?? 'N/A') === 'N/A' ? null : (string)$row['hostname'],
|
':hostname' => ($row['hostname'] ?? 'N/A') === 'N/A' ? null : (string)$row['hostname'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -3434,6 +3466,7 @@ function lan_wol_candidate_macs(array $leases, string $iface = 'eth1'): array
|
|||||||
SELECT mac
|
SELECT mac
|
||||||
FROM lan_wol_clients
|
FROM lan_wol_clients
|
||||||
WHERE iface = :iface
|
WHERE iface = :iface
|
||||||
|
AND last_ip IS NOT NULL
|
||||||
ORDER BY last_seen_at DESC
|
ORDER BY last_seen_at DESC
|
||||||
");
|
");
|
||||||
$stmt->execute([':iface' => $iface]);
|
$stmt->execute([':iface' => $iface]);
|
||||||
@@ -3441,15 +3474,16 @@ function lan_wol_candidate_macs(array $leases, string $iface = 'eth1'): array
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$macs = [];
|
|
||||||
foreach ($stmt->fetchAll() as $row) {
|
foreach ($stmt->fetchAll() as $row) {
|
||||||
$mac = strtolower((string)($row['mac'] ?? ''));
|
$mac = strtolower((string)($row['mac'] ?? ''));
|
||||||
if (wifi_valid_mac($mac) && isset($leases[$mac])) {
|
if (wifi_valid_mac($mac) && isset($leases[$mac])) {
|
||||||
$macs[$mac] = true;
|
// eth1 is a dedicated direct-connect PC port. Only its most recently
|
||||||
|
// observed lease-backed identity should remain as the offline WOL row.
|
||||||
|
return [$mac];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_keys($macs);
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function lan_client_rows(array $leases, array $aliases, array $wifiMacs = [], string $iface = 'eth1'): array
|
function lan_client_rows(array $leases, array $aliases, array $wifiMacs = [], string $iface = 'eth1'): array
|
||||||
|
|||||||
@@ -207,10 +207,13 @@
|
|||||||
logStatusLoading: 'Log status loading...',
|
logStatusLoading: 'Log status loading...',
|
||||||
logPersistentJournal: 'Persistent Journal',
|
logPersistentJournal: 'Persistent Journal',
|
||||||
logCrashLogs: 'Crash Logs',
|
logCrashLogs: 'Crash Logs',
|
||||||
|
logBootCapture: 'Boot Diagnostics',
|
||||||
logOk: 'OK',
|
logOk: 'OK',
|
||||||
logWarn: 'Check',
|
logWarn: 'Check',
|
||||||
logUsage: 'Usage',
|
logUsage: 'Usage',
|
||||||
logBoots: 'Boots',
|
logBoots: 'Boots',
|
||||||
|
logEvents: 'Events',
|
||||||
|
logLatest: 'Latest',
|
||||||
logFilterAll: 'All',
|
logFilterAll: 'All',
|
||||||
logFilterImportant: 'Important',
|
logFilterImportant: 'Important',
|
||||||
logFilterError: 'Error',
|
logFilterError: 'Error',
|
||||||
@@ -431,10 +434,13 @@
|
|||||||
logStatusLoading: '로그 보존 상태 확인 중...',
|
logStatusLoading: '로그 보존 상태 확인 중...',
|
||||||
logPersistentJournal: '영구 저널',
|
logPersistentJournal: '영구 저널',
|
||||||
logCrashLogs: '커널 크래시',
|
logCrashLogs: '커널 크래시',
|
||||||
|
logBootCapture: '부팅 원인 기록',
|
||||||
logOk: '정상',
|
logOk: '정상',
|
||||||
logWarn: '확인',
|
logWarn: '확인',
|
||||||
logUsage: '사용량',
|
logUsage: '사용량',
|
||||||
logBoots: '부팅 이력',
|
logBoots: '부팅 이력',
|
||||||
|
logEvents: '기록',
|
||||||
|
logLatest: '최근 판정',
|
||||||
logFilterAll: '전체',
|
logFilterAll: '전체',
|
||||||
logFilterImportant: '중요',
|
logFilterImportant: '중요',
|
||||||
logFilterError: '오류',
|
logFilterError: '오류',
|
||||||
@@ -2096,6 +2102,8 @@
|
|||||||
|
|
||||||
const journal = data.journal || {};
|
const journal = data.journal || {};
|
||||||
const pstore = data.pstore || {};
|
const pstore = data.pstore || {};
|
||||||
|
const bootCapture = data.boot_capture || {};
|
||||||
|
const latestBoot = bootCapture.latest || {};
|
||||||
const rows = [
|
const rows = [
|
||||||
{
|
{
|
||||||
title: t('logPersistentJournal'),
|
title: t('logPersistentJournal'),
|
||||||
@@ -2117,6 +2125,17 @@
|
|||||||
]),
|
]),
|
||||||
state: logStatusState(Boolean(pstore.ok), true),
|
state: logStatusState(Boolean(pstore.ok), true),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('logBootCapture'),
|
||||||
|
desc: bootCapture.path || '-',
|
||||||
|
meta: logStatusMeta([
|
||||||
|
{ label: 'recorder', value: bootCapture.active || '-' },
|
||||||
|
{ label: t('logEvents'), value: Number(bootCapture.count || 0).toLocaleString() },
|
||||||
|
{ label: t('logLatest'), value: latestBoot.classification || '-' },
|
||||||
|
{ label: t('logBoots'), value: latestBoot.booted_at || '-' },
|
||||||
|
]),
|
||||||
|
state: logStatusState(Boolean(bootCapture.ok), true),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
els.logStatusList.innerHTML = rows.map(row => `
|
els.logStatusList.innerHTML = rows.map(row => `
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Capture previous boot diagnostics for Control
|
||||||
|
Documentation=file:/var/www/control/README.md
|
||||||
|
After=local-fs.target systemd-journald.service systemd-pstore.service control-pstore-collect.service mariadb.service
|
||||||
|
Wants=systemd-pstore.service control-pstore-collect.service mariadb.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/var/www/control/bin/control_boot_capture.sh
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Archive pstore crash logs for Control
|
||||||
|
After=local-fs.target systemd-pstore.service
|
||||||
|
Wants=systemd-pstore.service
|
||||||
|
Before=control-boot-capture.service multi-user.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/var/www/control/bin/control_pstore_collect.sh
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[Timer]
|
||||||
|
OnCalendar=
|
||||||
|
OnCalendar=*-*-* *:*:00
|
||||||
|
AccuracySec=5s
|
||||||
|
Persistent=true
|
||||||
+118
-341
@@ -1,4 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import socket
|
import socket
|
||||||
@@ -27,103 +29,22 @@ NET_GROUPS = {
|
|||||||
"wifi5": ["wlan1"],
|
"wifi5": ["wlan1"],
|
||||||
}
|
}
|
||||||
|
|
||||||
PC_IP = "192.168.50.112"
|
PC_IP = "192.168.50.34"
|
||||||
|
PC_MAC = "00:00:00:00:00:d7"
|
||||||
PC_DHCP_LEASE_FILE = "/var/lib/misc/dnsmasq.leases"
|
PC_DHCP_LEASE_FILE = "/var/lib/misc/dnsmasq.leases"
|
||||||
SYNOLOGY_PING_HOST = "chaegeon.com"
|
SYNOLOGY_PING_HOST = "chaegeon.com"
|
||||||
SYNOLOGY_SSH = [
|
|
||||||
"/usr/bin/ssh",
|
|
||||||
"-i",
|
|
||||||
"/home/seo/.ssh/id_ed25519",
|
|
||||||
"-o",
|
|
||||||
"BatchMode=yes",
|
|
||||||
"-o",
|
|
||||||
"IdentitiesOnly=yes",
|
|
||||||
"-o",
|
|
||||||
"ConnectTimeout=5",
|
|
||||||
"-p",
|
|
||||||
"1026",
|
|
||||||
"seo@chaegeon.com",
|
|
||||||
]
|
|
||||||
SYNOLOGY_STATUS_SCRIPT = r"""
|
|
||||||
health_tick=49
|
|
||||||
while :; do
|
|
||||||
wan="$(awk '$2=="00000000"{print $1; exit}' /proc/net/route)"
|
|
||||||
[ -n "$wan" ] || wan=ovs_eth0
|
|
||||||
echo "WAN_IFACE=$wan"
|
|
||||||
awk -v wan="$wan" '
|
|
||||||
NR > 2 {
|
|
||||||
split($0, row, ":")
|
|
||||||
iface = row[1]
|
|
||||||
gsub(/^[ \t]+|[ \t]+$/, "", iface)
|
|
||||||
data = row[2]
|
|
||||||
gsub(/^[ \t]+/, "", data)
|
|
||||||
split(data, f, /[ \t]+/)
|
|
||||||
}
|
|
||||||
iface == wan {printf "WAN_RX=%s\nWAN_TX=%s\nWAN_RX_ERR=%s\nWAN_TX_ERR=%s\nWAN_RX_DROP=%s\nWAN_TX_DROP=%s\n", f[1], f[9], f[3], f[11], f[4], f[12]}
|
|
||||||
iface == "lo" {printf "LOCAL_RX=%s\nLOCAL_TX=%s\nLOCAL_RX_ERR=%s\nLOCAL_TX_ERR=%s\nLOCAL_RX_DROP=%s\nLOCAL_TX_DROP=%s\n", f[1], f[9], f[3], f[11], f[4], f[12]}
|
|
||||||
' /proc/net/dev
|
|
||||||
awk '
|
|
||||||
$3 == "sata1" {printf "SATA1_READS=%s\nSATA1_WRITES=%s\n", $6, $10}
|
|
||||||
$3 == "sata2" {printf "SATA2_READS=%s\nSATA2_WRITES=%s\n", $6, $10}
|
|
||||||
$3 == "sata4" {printf "SATA4_READS=%s\nSATA4_WRITES=%s\n", $6, $10}
|
|
||||||
' /proc/diskstats
|
|
||||||
awk '
|
|
||||||
/^cpu / {
|
|
||||||
total=0
|
|
||||||
for (i=2; i<=NF; i++) total += $i
|
|
||||||
printf "CPU_TOTAL=%s\nCPU_IDLE=%s\nCPU_USER=%s\nCPU_NICE=%s\nCPU_SYSTEM=%s\nCPU_IOWAIT=%s\nCPU_IRQ=%s\nCPU_SOFTIRQ=%s\n", total, $5 + $6, $2, $3, $4, $6, $7, $8
|
|
||||||
}
|
|
||||||
/^intr / {printf "INTR=%s\n", $2}
|
|
||||||
/^ctxt / {printf "CTXT=%s\n", $2}
|
|
||||||
/^processes / {printf "PROCESSES=%s\n", $2}
|
|
||||||
/^procs_running / {printf "PROCS_RUNNING=%s\n", $2}
|
|
||||||
/^procs_blocked / {printf "PROCS_BLOCKED=%s\n", $2}
|
|
||||||
/^softirq / {printf "SOFTIRQ=%s\n", $2}
|
|
||||||
' /proc/stat
|
|
||||||
awk '
|
|
||||||
$1 == "Tcp:" && tcp_header == 0 {for (i=2; i<=NF; i++) tcp_key[i]=$i; tcp_header=1; next}
|
|
||||||
$1 == "Tcp:" && tcp_header == 1 {for (i=2; i<=NF; i++) tcp[tcp_key[i]]=$i; tcp_header=0; next}
|
|
||||||
$1 == "Udp:" && udp_header == 0 {for (i=2; i<=NF; i++) udp_key[i]=$i; udp_header=1; next}
|
|
||||||
$1 == "Udp:" && udp_header == 1 {for (i=2; i<=NF; i++) udp[udp_key[i]]=$i; udp_header=0; next}
|
|
||||||
END {
|
|
||||||
printf "TCP_IN=%s\nTCP_OUT=%s\nTCP_RETRANS=%s\n", tcp["InSegs"] + 0, tcp["OutSegs"] + 0, tcp["RetransSegs"] + 0
|
|
||||||
printf "UDP_IN=%s\nUDP_OUT=%s\nUDP_ERRORS=%s\n", udp["InDatagrams"] + 0, udp["OutDatagrams"] + 0, udp["InErrors"] + udp["RcvbufErrors"] + udp["SndbufErrors"] + 0
|
|
||||||
}
|
|
||||||
' /proc/net/snmp
|
|
||||||
awk '/^(pgfault|pgmajfault|pgpgin|pgpgout) / {key=toupper($1); printf "%s=%s\n", key, $2}' /proc/vmstat
|
|
||||||
if [ "$health_tick" -ge 49 ]; then
|
|
||||||
cpu="$(cat /sys/class/hwmon/hwmon0/temp1_input 2>/dev/null)"
|
|
||||||
fan="$(cat /sys/class/hwmon/hwmon1/fan2_input 2>/dev/null)"
|
|
||||||
pwm="$(cat /sys/class/hwmon/hwmon1/pwm2 2>/dev/null)"
|
|
||||||
enable="$(cat /sys/class/hwmon/hwmon1/pwm2_enable 2>/dev/null)"
|
|
||||||
guard="$(systemctl is-active synology-fan-guard.service 2>/dev/null || printf unknown)"
|
|
||||||
case "$cpu" in ''|*[!0-9]*) cpu=0 ;; esac
|
|
||||||
case "$fan" in ''|*[!0-9]*) fan=0 ;; esac
|
|
||||||
case "$pwm" in ''|*[!0-9]*) pwm=0 ;; esac
|
|
||||||
case "$enable" in ''|*[!0-9]*) enable=-1 ;; esac
|
|
||||||
awk -v cpu="$cpu" -v fan="$fan" -v pwm="$pwm" -v enable="$enable" -v guard="$guard" 'BEGIN {printf "SYNO_CPU=%.1f\nSYNO_FAN=%d\nSYNO_GUARD=%s\nSYNO_PWM=%d\nSYNO_PWM_ENABLE=%d\n", cpu / 1000, fan, guard, pwm, enable}'
|
|
||||||
health_tick=0
|
|
||||||
else
|
|
||||||
health_tick=$((health_tick + 1))
|
|
||||||
fi
|
|
||||||
echo END=1
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
"""
|
|
||||||
SAMPLE_INTERVAL_MS = 100
|
SAMPLE_INTERVAL_MS = 100
|
||||||
DASHBOARD_INTERVAL_MS = 500
|
|
||||||
FAN_INTERVAL_MS = 500
|
FAN_INTERVAL_MS = 500
|
||||||
WAN_GRAPH_SECONDS = 10
|
WAN_GRAPH_SECONDS = 20
|
||||||
TEMP_GRAPH_SECONDS = 60
|
TEMP_GRAPH_SECONDS = 20
|
||||||
WAN_GRAPH_POINT_DENSITY = 2
|
WAN_GRAPH_POINTS = WAN_GRAPH_SECONDS * 1000 // SAMPLE_INTERVAL_MS
|
||||||
WAN_GRAPH_POINTS = WAN_GRAPH_SECONDS * 1000 // SAMPLE_INTERVAL_MS * WAN_GRAPH_POINT_DENSITY
|
TEMP_GRAPH_POINTS = TEMP_GRAPH_SECONDS * 1000 // SAMPLE_INTERVAL_MS
|
||||||
TEMP_GRAPH_POINTS = TEMP_GRAPH_SECONDS * 1000 // DASHBOARD_INTERVAL_MS
|
|
||||||
SYNOLOGY_RESTART_INTERVAL_MS = 3000
|
|
||||||
TEMP_INTERVAL_MS = 500
|
TEMP_INTERVAL_MS = 500
|
||||||
DISK_TEMP_INTERVAL_MS = TEMP_INTERVAL_MS
|
DISK_TEMP_INTERVAL_MS = TEMP_INTERVAL_MS
|
||||||
DISK_TEMP_CACHE_FILE = "/run/control-rpi-disk-temp"
|
DISK_TEMP_CACHE_FILE = "/run/control-rpi-disk-temp"
|
||||||
DISK_TEMP_CACHE_MAX_AGE_SECONDS = 30
|
DISK_TEMP_CACHE_MAX_AGE_SECONDS = 30
|
||||||
RPI_THROTTLE_INTERVAL_MS = SAMPLE_INTERVAL_MS
|
RPI_THROTTLE_INTERVAL_MS = SAMPLE_INTERVAL_MS
|
||||||
|
SYSTEM_INTERVAL_MS = 1000
|
||||||
|
|
||||||
|
|
||||||
def byte_rate_unit(values):
|
def byte_rate_unit(values):
|
||||||
@@ -180,6 +101,35 @@ def cpu_temp():
|
|||||||
return round(raw / 1000, 1) if raw > 0 else 0.0
|
return round(raw / 1000, 1) if raw > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def load_average():
|
||||||
|
try:
|
||||||
|
return float(read_text("/proc/loadavg").split()[0])
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def memory_used_percent():
|
||||||
|
values = {}
|
||||||
|
for line in read_text("/proc/meminfo").splitlines():
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
key, rest = line.split(":", 1)
|
||||||
|
parts = rest.strip().split()
|
||||||
|
if parts and parts[0].isdigit():
|
||||||
|
values[key] = int(parts[0])
|
||||||
|
total = values.get("MemTotal", 0)
|
||||||
|
available = values.get("MemAvailable", 0)
|
||||||
|
return None if total <= 0 else (total - available) * 100 / total
|
||||||
|
|
||||||
|
|
||||||
|
def disk_used_percent(path):
|
||||||
|
try:
|
||||||
|
usage = shutil.disk_usage(path)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return usage.used * 100 / usage.total if usage.total > 0 else None
|
||||||
|
|
||||||
|
|
||||||
def cached_disk_temp():
|
def cached_disk_temp():
|
||||||
values = {}
|
values = {}
|
||||||
for line in read_text(DISK_TEMP_CACHE_FILE).splitlines():
|
for line in read_text(DISK_TEMP_CACHE_FILE).splitlines():
|
||||||
@@ -353,13 +303,17 @@ def normalize_mac(mac):
|
|||||||
return ":".join(clean[i:i + 2] for i in range(0, 12, 2)).lower()
|
return ":".join(clean[i:i + 2] for i in range(0, 12, 2)).lower()
|
||||||
|
|
||||||
|
|
||||||
def dhcp_mac_for_ip(ip):
|
def dhcp_ip_for_mac(mac):
|
||||||
|
mac = normalize_mac(mac)
|
||||||
|
if not mac:
|
||||||
|
return ""
|
||||||
for line in read_text(PC_DHCP_LEASE_FILE).splitlines():
|
for line in read_text(PC_DHCP_LEASE_FILE).splitlines():
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
if len(parts) >= 3 and parts[2] == ip:
|
if len(parts) >= 3 and normalize_mac(parts[1]) == mac:
|
||||||
mac = normalize_mac(parts[1])
|
ip = parts[2]
|
||||||
if mac:
|
octets = ip.split(".")
|
||||||
return mac
|
if len(octets) == 4 and all(part.isdigit() and 0 <= int(part) <= 255 for part in octets):
|
||||||
|
return ip
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -566,13 +520,13 @@ class PcWakeButton(StatusTile):
|
|||||||
self.error_until = 0.0
|
self.error_until = 0.0
|
||||||
self.error_dot.set_steady(False)
|
self.error_dot.set_steady(False)
|
||||||
self.detail.setText("깨우는 중")
|
self.detail.setText("깨우는 중")
|
||||||
mac = dhcp_mac_for_ip(PC_IP)
|
pc_ip = dhcp_ip_for_mac(PC_MAC) or PC_IP
|
||||||
if not mac or not send_magic_packet(mac, PC_IP):
|
if not send_magic_packet(PC_MAC, pc_ip):
|
||||||
self.wait_until = 0.0
|
self.wait_until = 0.0
|
||||||
self.error_until = now + 5
|
self.error_until = now + 5
|
||||||
self.error_dot.set_steady(True)
|
self.error_dot.set_steady(True)
|
||||||
self.write_dot.set_steady(False)
|
self.write_dot.set_steady(False)
|
||||||
self.detail.setText("MAC 없음" if not mac else "실패")
|
self.detail.setText("실패")
|
||||||
super().mousePressEvent(event)
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
def tick(self):
|
def tick(self):
|
||||||
@@ -687,30 +641,18 @@ class TouchPanel(QMainWindow):
|
|||||||
self.wan_iface = default_iface()
|
self.wan_iface = default_iface()
|
||||||
NET_GROUPS["wan"] = [self.wan_iface]
|
NET_GROUPS["wan"] = [self.wan_iface]
|
||||||
self.last_net = {}
|
self.last_net = {}
|
||||||
self.last_synology = {}
|
|
||||||
self.last_synology_net_rate = {}
|
|
||||||
self.last_rpi_activity = {}
|
self.last_rpi_activity = {}
|
||||||
self.last_synology_activity = {}
|
|
||||||
self.synology_buffer = ""
|
|
||||||
self.synology_values = {}
|
|
||||||
self.synology_cpu_temp = 0.0
|
|
||||||
self.synology_fan_rpm = 0
|
|
||||||
self.synology_guard_values = {}
|
|
||||||
self.last_io = None
|
self.last_io = None
|
||||||
self.last_sample = None
|
self.last_sample = None
|
||||||
self.last_graph = None
|
self.last_graph = None
|
||||||
self.current_rates = {"wan": {"rx": 0.0, "tx": 0.0}}
|
self.current_rates = {"wan": {"rx": 0.0, "tx": 0.0}}
|
||||||
self.current_synology_rates = {"wan": {"rx": 0.0, "tx": 0.0}}
|
|
||||||
self.disk_temp = None
|
self.disk_temp = None
|
||||||
self.latest_temp = 0.0
|
self.latest_temp = 0.0
|
||||||
self.latest_rpm = 0
|
self.latest_rpm = 0
|
||||||
self.rpi_temp_history = deque(maxlen=TEMP_GRAPH_POINTS)
|
self.rpi_temp_history = deque(maxlen=TEMP_GRAPH_POINTS)
|
||||||
self.rpi_rpm_history = deque(maxlen=TEMP_GRAPH_POINTS)
|
self.rpi_rpm_history = deque(maxlen=TEMP_GRAPH_POINTS)
|
||||||
self.synology_temp_history = deque(maxlen=TEMP_GRAPH_POINTS)
|
|
||||||
self.synology_rpm_history = deque(maxlen=TEMP_GRAPH_POINTS)
|
|
||||||
self.net_history = {
|
self.net_history = {
|
||||||
"wan": {"rx": deque(maxlen=WAN_GRAPH_POINTS), "tx": deque(maxlen=WAN_GRAPH_POINTS)},
|
"wan": {"rx": deque(maxlen=WAN_GRAPH_POINTS), "tx": deque(maxlen=WAN_GRAPH_POINTS)},
|
||||||
"synology_wan": {"rx": deque(maxlen=WAN_GRAPH_POINTS), "tx": deque(maxlen=WAN_GRAPH_POINTS)},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.setWindowTitle("Standalone Touch Records")
|
self.setWindowTitle("Standalone Touch Records")
|
||||||
@@ -736,12 +678,9 @@ class TouchPanel(QMainWindow):
|
|||||||
self.disk_temp_timer.timeout.connect(self.update_disk_temp)
|
self.disk_temp_timer.timeout.connect(self.update_disk_temp)
|
||||||
self.disk_temp_timer.start(DISK_TEMP_INTERVAL_MS)
|
self.disk_temp_timer.start(DISK_TEMP_INTERVAL_MS)
|
||||||
|
|
||||||
self.synology_proc = QProcess(self)
|
self.system_timer = QTimer(self)
|
||||||
self.synology_proc.readyReadStandardOutput.connect(self.synology_status_ready)
|
self.system_timer.timeout.connect(self.update_system_status)
|
||||||
self.synology_proc.finished.connect(self.synology_status_finished)
|
self.system_timer.start(SYSTEM_INTERVAL_MS)
|
||||||
self.synology_timer = QTimer(self)
|
|
||||||
self.synology_timer.timeout.connect(self.update_synology_status)
|
|
||||||
self.synology_timer.start(SYNOLOGY_RESTART_INTERVAL_MS)
|
|
||||||
|
|
||||||
self.synology_ping_proc = QProcess(self)
|
self.synology_ping_proc = QProcess(self)
|
||||||
self.synology_ping_buffer = ""
|
self.synology_ping_buffer = ""
|
||||||
@@ -765,50 +704,65 @@ class TouchPanel(QMainWindow):
|
|||||||
self.update_fan_status()
|
self.update_fan_status()
|
||||||
self.ping_pc()
|
self.ping_pc()
|
||||||
self.update_disk_temp()
|
self.update_disk_temp()
|
||||||
self.update_synology_status()
|
self.update_system_status()
|
||||||
self.ping_synology()
|
self.ping_synology()
|
||||||
self.update_rpi_throttle()
|
self.update_rpi_throttle()
|
||||||
|
|
||||||
def _build_ui(self):
|
def _build_ui(self):
|
||||||
root = QWidget()
|
root = QWidget()
|
||||||
layout = QVBoxLayout(root)
|
layout = QHBoxLayout(root)
|
||||||
layout.setContentsMargins(4, 8, 10, 8)
|
layout.setContentsMargins(4, 8, 10, 8)
|
||||||
layout.setSpacing(6)
|
layout.setSpacing(8)
|
||||||
|
|
||||||
panel_layout = QHBoxLayout()
|
status_rows = [
|
||||||
panel_layout.setContentsMargins(0, 0, 0, 0)
|
(("io", "SSD I/O"), ("synology_ping", "PING"), ("rpi_fan", "FAN")),
|
||||||
panel_layout.setSpacing(8)
|
|
||||||
|
|
||||||
rpi_rows = [
|
|
||||||
(("io", "SSD I/O"), ("rpi_temp", "TEMP"), ("rpi_fan", "FAN")),
|
|
||||||
(("rpi_throttle", "THROT"), ("rpi_cpu_activity", "CPU"), ("rpi_irq", "IRQ")),
|
(("rpi_throttle", "THROT"), ("rpi_cpu_activity", "CPU"), ("rpi_irq", "IRQ")),
|
||||||
(("rpi_ctx", "CTX"), ("rpi_fork", "FORK"), ("local", "LOCAL")),
|
(("rpi_ctx", "CTX"), ("rpi_fork", "FORK"), ("local", "LOCAL")),
|
||||||
(("wan", "UPLINK"), ("lan", "ETH1"), ("wifi5", "5G")),
|
(("wan", "UPLINK"), ("lan", "ETH1"), ("wifi5", "5G")),
|
||||||
(("wifi24", "2.4G"), ("rpi_softirq", "SOFTIRQ"), ("rpi_tcp", "TCP")),
|
(("wifi24", "2.4G"), ("rpi_softirq", "SOFTIRQ"), ("rpi_tcp", "TCP")),
|
||||||
(("rpi_udp", "UDP"), ("rpi_vm", "VM"), ("rpi_runq", "RUNQ")),
|
(("rpi_udp", "UDP"), ("rpi_vm", "VM"), ("rpi_runq", "RUNQ")),
|
||||||
|
(("rpi_load", "LOAD"), ("rpi_mem", "MEM"), ("rpi_root", "ROOT")),
|
||||||
]
|
]
|
||||||
synology_rows = [
|
|
||||||
(("synology_ping", "PING"), ("synology_temp", "TEMP"), ("synology_fan", "FAN")),
|
|
||||||
(("synology_guard", "GUARD"), ("synology_cpu_activity", "CPU"), ("synology_irq", "IRQ")),
|
|
||||||
(("synology_ctx", "CTX"), ("synology_fork", "FORK"), ("synology_softirq", "SOFTIRQ")),
|
|
||||||
(("synology_tcp", "TCP"), ("synology_udp", "UDP"), ("synology_vm", "VM")),
|
|
||||||
(("synology_local", "LOCAL"), ("synology_wan", "UPLINK"), ("synology_runq", "RUNQ")),
|
|
||||||
(("synology_sata1", "SATA1 I/O"), ("synology_sata2", "SATA2 I/O"), ("synology_sata4", "SATA4 I/O")),
|
|
||||||
]
|
|
||||||
|
|
||||||
left = self.build_status_panel("RASPBERRY", rpi_rows, fixed_width=398)
|
|
||||||
right = self.build_status_panel("SYNOLOGY", synology_rows)
|
|
||||||
|
|
||||||
panel_layout.addWidget(left)
|
|
||||||
panel_layout.addWidget(right, 1)
|
|
||||||
layout.addLayout(panel_layout, 1)
|
|
||||||
|
|
||||||
self.pc_button = PcWakeButton()
|
self.pc_button = PcWakeButton()
|
||||||
self.pc_button.setFixedHeight(48)
|
self.pc_button.setFixedHeight(48)
|
||||||
layout.addWidget(self.pc_button)
|
left = self.build_status_panel(None, status_rows, fixed_width=398, footer_widget=self.pc_button)
|
||||||
|
|
||||||
|
right = QWidget()
|
||||||
|
right_layout = QVBoxLayout(right)
|
||||||
|
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
right_layout.setSpacing(8)
|
||||||
|
for key, chart in [
|
||||||
|
(
|
||||||
|
"rpi_temp",
|
||||||
|
Sparkline(
|
||||||
|
"온도 / RPM",
|
||||||
|
"#f97316",
|
||||||
|
"#38bdf8",
|
||||||
|
"C",
|
||||||
|
"rpm",
|
||||||
|
max_points=TEMP_GRAPH_POINTS,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"rpi_wan",
|
||||||
|
Sparkline(
|
||||||
|
"WAN 트래픽",
|
||||||
|
"#f97316",
|
||||||
|
"#22c55e",
|
||||||
|
auto_byte_unit=True,
|
||||||
|
max_points=WAN_GRAPH_POINTS,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]:
|
||||||
|
self.charts[key] = chart
|
||||||
|
right_layout.addWidget(chart, 1)
|
||||||
|
|
||||||
|
layout.addWidget(left)
|
||||||
|
layout.addWidget(right, 1)
|
||||||
self.setCentralWidget(root)
|
self.setCentralWidget(root)
|
||||||
|
|
||||||
def build_status_panel(self, title_text, rows, fixed_width=None, bottom_blank=False):
|
def build_status_panel(self, title_text, rows, fixed_width=None, footer_widget=None):
|
||||||
panel = QFrame()
|
panel = QFrame()
|
||||||
panel.setObjectName("statusPanel")
|
panel.setObjectName("statusPanel")
|
||||||
if fixed_width:
|
if fixed_width:
|
||||||
@@ -817,9 +771,10 @@ class TouchPanel(QMainWindow):
|
|||||||
panel_layout.setContentsMargins(6, 8, 6, 8)
|
panel_layout.setContentsMargins(6, 8, 6, 8)
|
||||||
panel_layout.setSpacing(6)
|
panel_layout.setSpacing(6)
|
||||||
|
|
||||||
title = QLabel(title_text)
|
if title_text:
|
||||||
title.setObjectName("sectionTitle")
|
title = QLabel(title_text)
|
||||||
panel_layout.addWidget(title)
|
title.setObjectName("sectionTitle")
|
||||||
|
panel_layout.addWidget(title)
|
||||||
|
|
||||||
grid = QGridLayout()
|
grid = QGridLayout()
|
||||||
grid.setContentsMargins(0, 0, 0, 0)
|
grid.setContentsMargins(0, 0, 0, 0)
|
||||||
@@ -831,11 +786,8 @@ class TouchPanel(QMainWindow):
|
|||||||
panel_layout.addLayout(grid)
|
panel_layout.addLayout(grid)
|
||||||
panel_layout.addStretch(1)
|
panel_layout.addStretch(1)
|
||||||
|
|
||||||
if bottom_blank:
|
if footer_widget is not None:
|
||||||
blank = QWidget()
|
panel_layout.addWidget(footer_widget)
|
||||||
blank.setFixedHeight(48)
|
|
||||||
blank.setObjectName("statusEmpty")
|
|
||||||
panel_layout.addWidget(blank)
|
|
||||||
|
|
||||||
return panel
|
return panel
|
||||||
|
|
||||||
@@ -891,7 +843,7 @@ class TouchPanel(QMainWindow):
|
|||||||
self.last_net[key] = current
|
self.last_net[key] = current
|
||||||
|
|
||||||
self.current_rates = rates
|
self.current_rates = rates
|
||||||
self.update_wan_graph()
|
self.update_graphs()
|
||||||
self.render_status(groups, rates, activity, io_read_activity, io_write_activity, disk_error)
|
self.render_status(groups, rates, activity, io_read_activity, io_write_activity, disk_error)
|
||||||
self.render_rpi_activity()
|
self.render_rpi_activity()
|
||||||
|
|
||||||
@@ -1007,7 +959,8 @@ class TouchPanel(QMainWindow):
|
|||||||
def ping_pc(self):
|
def ping_pc(self):
|
||||||
if self.pc_ping.state() != QProcess.NotRunning:
|
if self.pc_ping.state() != QProcess.NotRunning:
|
||||||
return
|
return
|
||||||
self.pc_ping.start("/usr/bin/ping", ["-c", "1", "-W", "1", PC_IP])
|
pc_ip = dhcp_ip_for_mac(PC_MAC) or PC_IP
|
||||||
|
self.pc_ping.start("/usr/bin/ping", ["-c", "1", "-W", "1", pc_ip])
|
||||||
|
|
||||||
def pc_ping_finished(self, exit_code, exit_status):
|
def pc_ping_finished(self, exit_code, exit_status):
|
||||||
del exit_status
|
del exit_status
|
||||||
@@ -1093,153 +1046,22 @@ class TouchPanel(QMainWindow):
|
|||||||
def update_disk_temp(self):
|
def update_disk_temp(self):
|
||||||
self.latest_temp = cpu_temp()
|
self.latest_temp = cpu_temp()
|
||||||
self.disk_temp = cached_disk_temp()
|
self.disk_temp = cached_disk_temp()
|
||||||
self.render_rpi_temp(self.latest_temp)
|
|
||||||
|
|
||||||
def update_synology_status(self):
|
def update_system_status(self):
|
||||||
if self.synology_proc.state() != QProcess.NotRunning:
|
cores = max(1, os.cpu_count() or 1)
|
||||||
return
|
load = load_average()
|
||||||
self.synology_buffer = ""
|
self.rows["rpi_load"].set_state(
|
||||||
self.synology_values = {}
|
f"{load:.2f}",
|
||||||
self.synology_proc.start(SYNOLOGY_SSH[0], SYNOLOGY_SSH[1:] + [SYNOLOGY_STATUS_SCRIPT])
|
read_steady=load < cores * 0.7,
|
||||||
|
write_steady=cores * 0.7 <= load < cores * 1.2,
|
||||||
def apply_synology_health(self, values):
|
error_steady=load >= cores * 1.2,
|
||||||
health_keys = ["SYNO_CPU", "SYNO_FAN", "SYNO_GUARD", "SYNO_PWM", "SYNO_PWM_ENABLE"]
|
|
||||||
if not any(key in values for key in health_keys):
|
|
||||||
return
|
|
||||||
if "SYNO_CPU" in values:
|
|
||||||
try:
|
|
||||||
self.synology_cpu_temp = float(values.get("SYNO_CPU", "0") or 0)
|
|
||||||
except ValueError:
|
|
||||||
self.synology_cpu_temp = 0.0
|
|
||||||
if "SYNO_FAN" in values:
|
|
||||||
try:
|
|
||||||
self.synology_fan_rpm = int(values.get("SYNO_FAN", "0") or 0)
|
|
||||||
except ValueError:
|
|
||||||
self.synology_fan_rpm = 0
|
|
||||||
self.synology_guard_values.update({key: values[key] for key in health_keys if key in values})
|
|
||||||
self.render_synology_temp()
|
|
||||||
self.render_synology_fan()
|
|
||||||
self.render_synology_guard(self.synology_guard_values)
|
|
||||||
|
|
||||||
def synology_status_ready(self):
|
|
||||||
output = bytes(self.synology_proc.readAllStandardOutput()).decode("utf-8", "ignore")
|
|
||||||
self.synology_buffer += output
|
|
||||||
while "\n" in self.synology_buffer:
|
|
||||||
line, self.synology_buffer = self.synology_buffer.split("\n", 1)
|
|
||||||
if line == "END=1":
|
|
||||||
self.render_synology_status(self.synology_values)
|
|
||||||
self.synology_values = {}
|
|
||||||
continue
|
|
||||||
if "=" not in line:
|
|
||||||
continue
|
|
||||||
key, value = line.split("=", 1)
|
|
||||||
self.synology_values[key.strip()] = value.strip()
|
|
||||||
|
|
||||||
def synology_status_finished(self, exit_code, exit_status):
|
|
||||||
del exit_code, exit_status
|
|
||||||
self.set_synology_offline()
|
|
||||||
|
|
||||||
def set_synology_offline(self):
|
|
||||||
for key in [
|
|
||||||
"synology_sata1",
|
|
||||||
"synology_sata2",
|
|
||||||
"synology_sata4",
|
|
||||||
"synology_wan",
|
|
||||||
"synology_local",
|
|
||||||
"synology_guard",
|
|
||||||
"synology_cpu_activity",
|
|
||||||
"synology_irq",
|
|
||||||
"synology_ctx",
|
|
||||||
"synology_fork",
|
|
||||||
"synology_softirq",
|
|
||||||
"synology_tcp",
|
|
||||||
"synology_udp",
|
|
||||||
"synology_vm",
|
|
||||||
"synology_runq",
|
|
||||||
]:
|
|
||||||
self.rows[key].set_state("-", read_steady=False, write_steady=False, error_steady=True)
|
|
||||||
|
|
||||||
def render_synology_status(self, values):
|
|
||||||
if not values:
|
|
||||||
self.set_synology_offline()
|
|
||||||
return
|
|
||||||
self.apply_synology_health(values)
|
|
||||||
for disk in ["SATA1", "SATA2", "SATA4"]:
|
|
||||||
self.set_synology_io_tile(
|
|
||||||
f"synology_{disk.lower()}",
|
|
||||||
int(values.get(f"{disk}_READS", "0") or 0),
|
|
||||||
int(values.get(f"{disk}_WRITES", "0") or 0),
|
|
||||||
self.synology_disk_temp_detail(disk),
|
|
||||||
)
|
|
||||||
self.set_synology_net_tile(
|
|
||||||
"synology_wan",
|
|
||||||
int(values.get("WAN_RX", "0") or 0),
|
|
||||||
int(values.get("WAN_TX", "0") or 0),
|
|
||||||
int(values.get("WAN_RX_ERR", "0") or 0) + int(values.get("WAN_TX_ERR", "0") or 0),
|
|
||||||
int(values.get("WAN_RX_DROP", "0") or 0) + int(values.get("WAN_TX_DROP", "0") or 0),
|
|
||||||
values.get("WAN_IFACE", "wan"),
|
|
||||||
)
|
|
||||||
self.set_synology_net_tile(
|
|
||||||
"synology_local",
|
|
||||||
int(values.get("LOCAL_RX", "0") or 0),
|
|
||||||
int(values.get("LOCAL_TX", "0") or 0),
|
|
||||||
int(values.get("LOCAL_RX_ERR", "0") or 0) + int(values.get("LOCAL_TX_ERR", "0") or 0),
|
|
||||||
int(values.get("LOCAL_RX_DROP", "0") or 0) + int(values.get("LOCAL_TX_DROP", "0") or 0),
|
|
||||||
"lo",
|
|
||||||
)
|
|
||||||
self.render_activity_tiles("synology", values, "last_synology_activity")
|
|
||||||
|
|
||||||
def set_synology_io_tile(self, key, reads, writes, detail):
|
|
||||||
previous = self.last_synology.get(key)
|
|
||||||
self.last_synology[key] = (reads, writes, 0, 0)
|
|
||||||
read_activity = previous is not None and reads > previous[0]
|
|
||||||
write_activity = previous is not None and writes > previous[1]
|
|
||||||
self.rows[key].set_state(detail, read_activity=read_activity, write_activity=write_activity, error_steady=False)
|
|
||||||
|
|
||||||
def render_rpi_temp(self, pi_temp):
|
|
||||||
pi_disk = self.disk_temp or 0.0
|
|
||||||
hottest = max(float(pi_temp), pi_disk)
|
|
||||||
detail = f"{hottest:.0f} C" if hottest > 0 else "- C"
|
|
||||||
self.rows["rpi_temp"].set_state(
|
|
||||||
detail,
|
|
||||||
read_steady=0 < hottest < 60,
|
|
||||||
write_steady=60 <= hottest < 80,
|
|
||||||
error_steady=False,
|
|
||||||
error_blink=hottest >= 80,
|
|
||||||
)
|
)
|
||||||
|
self.set_percent_tile("rpi_mem", memory_used_percent(), warn=80, critical=92)
|
||||||
|
self.set_percent_tile("rpi_root", disk_used_percent("/"), warn=80, critical=92)
|
||||||
|
|
||||||
def render_rpi_fan(self, rpm):
|
def render_rpi_fan(self, rpm):
|
||||||
self.render_fan_tile("rpi_fan", rpm)
|
self.render_fan_tile("rpi_fan", rpm)
|
||||||
|
|
||||||
def render_synology_temp(self):
|
|
||||||
temp = self.synology_cpu_temp
|
|
||||||
detail = f"{temp:.0f} C" if temp > 0 else "- C"
|
|
||||||
self.rows["synology_temp"].set_state(
|
|
||||||
detail,
|
|
||||||
read_steady=0 < temp < 60,
|
|
||||||
write_steady=60 <= temp < 80,
|
|
||||||
error_steady=False,
|
|
||||||
error_blink=temp >= 80,
|
|
||||||
)
|
|
||||||
|
|
||||||
def render_synology_fan(self):
|
|
||||||
self.render_fan_tile("synology_fan", self.synology_fan_rpm)
|
|
||||||
|
|
||||||
def render_synology_guard(self, values):
|
|
||||||
guard = values.get("SYNO_GUARD", "unknown")
|
|
||||||
try:
|
|
||||||
pwm = int(values.get("SYNO_PWM", "0") or 0)
|
|
||||||
enable = int(values.get("SYNO_PWM_ENABLE", "-1") or -1)
|
|
||||||
except ValueError:
|
|
||||||
pwm = 0
|
|
||||||
enable = -1
|
|
||||||
if guard != "active" or self.synology_fan_rpm <= 0:
|
|
||||||
self.rows["synology_guard"].set_state("FAIL", read_steady=False, write_steady=False, error_steady=True)
|
|
||||||
elif pwm >= 255 or enable != 5:
|
|
||||||
self.rows["synology_guard"].set_state("FULL", read_steady=False, write_steady=True, error_steady=False)
|
|
||||||
else:
|
|
||||||
self.rows["synology_guard"].set_state("AUTO", read_steady=True, write_steady=False, error_steady=False)
|
|
||||||
|
|
||||||
def render_fan_tile(self, key, rpm):
|
def render_fan_tile(self, key, rpm):
|
||||||
detail = f"{int(rpm):,} rpm" if rpm > 0 else "0 rpm"
|
detail = f"{int(rpm):,} rpm" if rpm > 0 else "0 rpm"
|
||||||
self.rows[key].set_state(
|
self.rows[key].set_state(
|
||||||
@@ -1249,39 +1071,9 @@ class TouchPanel(QMainWindow):
|
|||||||
error_steady=rpm == 0 or rpm > 7000,
|
error_steady=rpm == 0 or rpm > 7000,
|
||||||
)
|
)
|
||||||
|
|
||||||
def synology_disk_temp_detail(self, disk):
|
|
||||||
del disk
|
|
||||||
return "조회 중지"
|
|
||||||
|
|
||||||
def set_synology_net_tile(self, key, rx, tx, errors, drops, detail):
|
|
||||||
previous = self.last_synology.get(key)
|
|
||||||
self.last_synology[key] = (rx, tx, errors, drops)
|
|
||||||
read_activity = previous is not None and tx > previous[1]
|
|
||||||
write_activity = previous is not None and rx > previous[0]
|
|
||||||
error_activity = previous is not None and (errors > previous[2] or drops > previous[3])
|
|
||||||
self.rows[key].set_state(self.drop_detail(drops, detail), error_activity, read_activity, write_activity)
|
|
||||||
if key == "synology_wan":
|
|
||||||
self.update_synology_wan_rate(rx, tx)
|
|
||||||
|
|
||||||
def update_synology_wan_rate(self, rx, tx):
|
|
||||||
now = time.monotonic()
|
|
||||||
previous = self.last_synology_net_rate.get("wan")
|
|
||||||
if previous is not None:
|
|
||||||
prev_rx, prev_tx, prev_time = previous
|
|
||||||
dt = max(0.001, now - prev_time)
|
|
||||||
self.current_synology_rates["wan"] = {
|
|
||||||
"rx": max(0.0, (rx - prev_rx) / dt),
|
|
||||||
"tx": max(0.0, (tx - prev_tx) / dt),
|
|
||||||
}
|
|
||||||
self.last_synology_net_rate["wan"] = (rx, tx, now)
|
|
||||||
self.update_synology_wan_graph()
|
|
||||||
|
|
||||||
def update_temp_graphs(self, rpi_temp, rpi_rpm):
|
def update_temp_graphs(self, rpi_temp, rpi_rpm):
|
||||||
if "rpi_temp" not in self.charts or "synology_temp" not in self.charts:
|
if "rpi_temp" not in self.charts:
|
||||||
return
|
return
|
||||||
synology_temp = getattr(self, "synology_cpu_temp", 0.0)
|
|
||||||
synology_rpm = getattr(self, "synology_fan_rpm", 0)
|
|
||||||
|
|
||||||
self.rpi_temp_history.append(rpi_temp)
|
self.rpi_temp_history.append(rpi_temp)
|
||||||
self.rpi_rpm_history.append(rpi_rpm)
|
self.rpi_rpm_history.append(rpi_rpm)
|
||||||
self.charts["rpi_temp"].set_values(
|
self.charts["rpi_temp"].set_values(
|
||||||
@@ -1290,13 +1082,10 @@ class TouchPanel(QMainWindow):
|
|||||||
self.rpi_rpm_history,
|
self.rpi_rpm_history,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.synology_temp_history.append(synology_temp)
|
def update_graphs(self):
|
||||||
self.synology_rpm_history.append(synology_rpm)
|
hottest = max(float(self.latest_temp), self.disk_temp or 0.0)
|
||||||
self.charts["synology_temp"].set_values(
|
self.update_temp_graphs(hottest, self.latest_rpm)
|
||||||
self.synology_temp_history,
|
self.update_wan_graph()
|
||||||
format_temp_rpm_caption(synology_temp, synology_rpm),
|
|
||||||
self.synology_rpm_history,
|
|
||||||
)
|
|
||||||
|
|
||||||
def update_wan_graph(self):
|
def update_wan_graph(self):
|
||||||
if "rpi_wan" not in self.charts:
|
if "rpi_wan" not in self.charts:
|
||||||
@@ -1310,18 +1099,6 @@ class TouchPanel(QMainWindow):
|
|||||||
self.net_history["wan"]["tx"],
|
self.net_history["wan"]["tx"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_synology_wan_graph(self):
|
|
||||||
if "synology_wan" not in self.charts:
|
|
||||||
return
|
|
||||||
wan = self.current_synology_rates.get("wan", {"rx": 0.0, "tx": 0.0})
|
|
||||||
self.net_history["synology_wan"]["rx"].append(wan["rx"])
|
|
||||||
self.net_history["synology_wan"]["tx"].append(wan["tx"])
|
|
||||||
self.charts["synology_wan"].set_values(
|
|
||||||
self.net_history["synology_wan"]["rx"],
|
|
||||||
format_wan_caption(wan["rx"], wan["tx"]),
|
|
||||||
self.net_history["synology_wan"]["tx"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def tick_pulses(self):
|
def tick_pulses(self):
|
||||||
for row in self.rows.values():
|
for row in self.rows.values():
|
||||||
row.tick()
|
row.tick()
|
||||||
|
|||||||
Reference in new issue
Block a user