Use Home Assistant notifications

This commit is contained in:
seo
2026-06-21 07:30:39 +09:00
parent 81a24530d6
commit 49394a8ee5
15 changed files with 234 additions and 2371 deletions
+33 -85
View File
@@ -1,10 +1,10 @@
# Control # Control
팬 제어, 시스템 모니터링, WiFi 제어, WakeLock, Web Push 알림을 제공하는 PHP 기반 단일 관리 패널입니다. 팬 제어, 시스템 모니터링, WiFi 제어, WakeLock, Home Assistant 알림을 제공하는 PHP 기반 단일 관리 패널입니다.
## 프로젝트 성격 ## 프로젝트 성격
Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 웹에서 관리하기 위한 내부 운영 도구입니다. 로그인 후 대시보드에서 온도, 팬 RPM, PWM, WiFi client, 배터리, notice, dmesg, process 후보, Push 구독 상태를 확인하고 필요한 제어 명령을 실행합니다. Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 웹에서 관리하기 위한 내부 운영 도구입니다. 로그인 후 대시보드에서 온도, 팬 RPM, PWM, WiFi client, 배터리, notice, dmesg, process 후보를 확인하고 필요한 제어 명령을 실행합니다.
상태 갱신은 WebSocket을 우선 사용하고, 연결 실패 시 HTTP fallback으로 전환합니다. 팬 정책 적용과 센서 수집은 백그라운드 작업과 API 호출을 통해 DB에 기록됩니다. 상태 갱신은 WebSocket을 우선 사용하고, 연결 실패 시 HTTP fallback으로 전환합니다. 팬 정책 적용과 센서 수집은 백그라운드 작업과 API 호출을 통해 DB에 기록됩니다.
@@ -16,18 +16,17 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
- 배터리 잔여 시간은 1S8P 병렬팩 `2200mAh x 8 = 17600mAh`, nominal `3.7V`, 총 `65.12Wh` 기준으로 계산 - 배터리 잔여 시간은 1S8P 병렬팩 `2200mAh x 8 = 17600mAh`, nominal `3.7V`, 총 `65.12Wh` 기준으로 계산
- 라즈베리파이 저전압/스로틀링 현재 상태, 복구 중/반복 발생 판정, 부팅 후 이력, 최근 감지 시각, 지속시간, 최근 10분 통계 표시 - 라즈베리파이 저전압/스로틀링 현재 상태, 복구 중/반복 발생 판정, 부팅 후 이력, 최근 감지 시각, 지속시간, 최근 10분 통계 표시
- WiFi client 목록과 2.4G/5G client 수 표시 - WiFi client 목록과 2.4G/5G client 수 표시
- WiFi client 상태 표시
- 5G 외부 WiFi 모듈이 연결 시간을 제공하지 않는 경우 서버 관측 기반 연결 시간 보정 - 5G 외부 WiFi 모듈이 연결 시간을 제공하지 않는 경우 서버 관측 기반 연결 시간 보정
- System Notice와 process CPU/MEM 후보 표시 - System Notice와 process CPU/MEM 후보 표시
- dmesg 로그 열람 - dmesg 로그 열람
- Web Push 구독 등록, 자동 복구, 구독 기기 목록 표시 - Home Assistant webhook 기반 Android 모바일 알림
- Push 기기 건강 상태 추적과 헬스체크 수동/자동 실행 - 배터리 SOC 20% 이하 경고를 동일 `tag`로 갱신해 알림이 과도하게 쌓이지 않도록 처리
- WakeLock 버튼으로 대시보드 화면 꺼짐 방지 - WakeLock 버튼으로 대시보드 화면 꺼짐 방지
- Reboot 버튼으로 2단계 확인 후 시스템 재부팅 요청 - Reboot 버튼으로 2단계 확인 후 시스템 재부팅 요청
- 기본 브라우저 alert/confirm/prompt 대신 대시보드 디자인에 맞춘 custom dialog 사용 - 기본 브라우저 alert/confirm/prompt 대신 대시보드 디자인에 맞춘 custom dialog 사용
- custom dialog는 Enter/Escape 키 입력을 자체 처리하며, Reboot 확인 흐름은 중복 실행을 차단 - custom dialog는 Enter/Escape 키 입력을 자체 처리하며, Reboot 확인 흐름은 중복 실행을 차단
- Translate 버튼으로 `en`, `ko` UI 언어 토글. 버튼 표시는 상태 suffix 없이 고정 - Translate 버튼으로 `en`, `ko` UI 언어 토글
- Theme 버튼으로 `dark`, `light` UI 테마 토글. 버튼 표시는 상태 suffix 없이 고정 - Theme 버튼으로 `dark`, `light` UI 테마 토글
## 주요 API ## 주요 API
@@ -35,10 +34,6 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
- `public/api.php?action=collect`: 센서 snapshot 수집 후 팬 정책 적용 - `public/api.php?action=collect`: 센서 snapshot 수집 후 팬 정책 적용
- `public/api.php?action=fan`: 팬 모드와 PWM 저장/적용 - `public/api.php?action=fan`: 팬 모드와 PWM 저장/적용
- `public/api.php?action=wifi`: 허용된 WiFi/DHCP service 제어 - `public/api.php?action=wifi`: 허용된 WiFi/DHCP service 제어
- `public/api.php?action=push_devices`: Push 구독 기기 목록 조회
- `public/api.php?action=push_status`: 브라우저 구독 endpoint의 서버 등록 상태 조회
- `public/api.php?action=send_push_healthcheck`: 등록된 Push 기기 전체에 상태 확인 알림 발송
- `public/api.php?action=delete_push_endpoint`: 현재 endpoint 구독 삭제
- `public/api.php?action=dmesg`: dmesg 로그 조회 - `public/api.php?action=dmesg`: dmesg 로그 조회
- `public/api.php?action=reboot`: 확인 단어와 관리자 암호 재검증 후 sudo reboot 실행 - `public/api.php?action=reboot`: 확인 단어와 관리자 암호 재검증 후 sudo reboot 실행
@@ -48,13 +43,9 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
- `public/api.php`: 상태 조회와 조작 API - `public/api.php`: 상태 조회와 조작 API
- `public/assets/app.js`: 대시보드 렌더링, WebSocket, 차트, 조작 이벤트 - `public/assets/app.js`: 대시보드 렌더링, WebSocket, 차트, 조작 이벤트
- `public/assets/wakelock.js`: Screen Wake Lock API 제어 - `public/assets/wakelock.js`: Screen Wake Lock API 제어
- `public/push_subscribe.js`: Push 구독 등록, 삭제, 자동 복구 - `config/config.php`: DB, 인증, CSRF, HA 알림, shell 실행 공통 함수
- `public/sw.js`: Push service worker
- `config/config.php`: DB, 인증, CSRF, Push, shell 실행 공통 함수
- `config/vapid.php`: 외부 secret 기반 VAPID 설정 bridge
- `apply_policy.php`: CLI/cron 팬 정책 적용 - `apply_policy.php`: CLI/cron 팬 정책 적용
- `bin/control_ws.php`: WebSocket 서버. 핵심 PHP 파일 변경 감지 시 종료되고 systemd가 재시작해 새 코드를 로드 - `bin/control_ws.php`: WebSocket 서버. 핵심 PHP 파일 변경 감지 시 종료되고 systemd가 재시작해 새 코드를 로드
- `bin/push_healthcheck.php`: 작업스케줄러용 Push 헬스체크 CLI
- `bin/wifi_observe.php`: 5G WiFi 연결 시간 보정을 위한 시스템단 관측 CLI - `bin/wifi_observe.php`: 5G WiFi 연결 시간 보정을 위한 시스템단 관측 CLI
- `systemd/control-wifi-observe.*`: WiFi 관측 CLI를 주기적으로 실행하는 systemd unit 예시 - `systemd/control-wifi-observe.*`: WiFi 관측 CLI를 주기적으로 실행하는 systemd unit 예시
@@ -67,61 +58,24 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
- `system_notice_state`: notice 활성 상태와 기준값 - `system_notice_state`: notice 활성 상태와 기준값
- `system_notice_logs`: notice 발생 이력 - `system_notice_logs`: notice 발생 이력
- `wifi_observed_sessions`: 5G WiFi client의 최초/마지막 감지 시간 - `wifi_observed_sessions`: 5G WiFi client의 최초/마지막 감지 시간
- `push_subscriptions`: Web Push 구독 정보 - `ha_notify_logs`: HA webhook 알림 발송 성공/실패 이력, 대상 서버, HTTP code, tag, 메타 정보
- `push_event_logs`: Push 발송, 수신, 표시, 클릭, 실패 이벤트 - `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 배터리 설정, 선택적 HA 알림 설정
- `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, VAPID key
- 배터리 용량 설정은 `/home/seo/secret/control.php``battery.cell_capacity_mah`, `battery.parallel_cells`, `battery.nominal_voltage`, `battery.capacity_wh`를 사용합니다. - 배터리 용량 설정은 `/home/seo/secret/control.php``battery.cell_capacity_mah`, `battery.parallel_cells`, `battery.nominal_voltage`, `battery.capacity_wh`를 사용합니다.
## Push 누락 방지 구조 ## HA 알림 구조
Control은 숨은 알림을 반복 발송하는 방식 대신 구독 자동 복구, 실제 수신 이벤트 기록, 발송 실패 추적, 저빈도 헬스체크를 조합합니다. Control은 브라우저 Web Push/VAPID 구독을 사용하지 않습니다. 서버가 Home Assistant webhook으로 알림 payload를 보내고, HA 자동화가 `notify.mobile_app_seocaegeonyi_z_fold7` 서비스로 Android 알림을 전달합니다.
### 클라이언트 자동 복구 - 기본 webhook id: `control_notify_20260621`
- 기본 발송 순서: 서울 HA `https://ha.seoul.chaegeon.com` 우선, 실패 시 목포 HA `https://ha.chaegeon.com`
- payload 형식: `title`, `message`, `data`
- Android 알림 속성은 `data`에 넣어 HA mobile app으로 전달
- 사용 속성: `tag`, `group`, `channel`, `importance`, `priority`, `ttl`, `sticky`, `persistent`, `renotify`, `color`, `notification_icon`, `vibrationPattern`, `ledColor`, `visibility`, `timeout`, `clickAction`, `url`, `actions`
- 발송 결과는 `ha_notify_logs`에 저장
- 대시보드 로드 직후 Push 구독 상태를 확인합니다. 배터리 SOC 20% 이하 경고는 `control-battery-low` tag를 사용합니다. SOC 값이 조금씩 달라져도 Android 알림은 같은 tag로 갱신되어 알림 목록에 여러 장이 계속 쌓이지 않습니다. 경고 상태가 유지되면 짧은 쿨다운 뒤 다시 발송될 수 있습니다.
- 화면이 다시 보일 때 구독 상태를 재확인합니다.
- 5분마다 브라우저 구독과 서버 등록 상태를 비교합니다.
- 브라우저 구독이 없으면 VAPID 공개키로 재구독합니다.
- 브라우저 구독은 있으나 서버에 누락되어 있으면 다시 저장합니다.
### 서버 상태 추적 System Notice는 `control-system-notice` tag를 사용하며, 10분 쿨다운으로 급격한 팬/온도 변화 알림을 제한합니다.
`push_subscriptions`에는 구독 정보와 함께 다음 상태가 저장됩니다.
- `last_send_success_at`: 서버가 endpoint로 Push 발송에 성공한 시간
- `last_send_failed_at`: 서버 발송 실패 시간
- `last_received_at`: Service Worker가 `push` 이벤트를 실제 수신한 시간
- `last_notification_at`: 알림 표시 성공 시간
- `last_click_at`: 사용자 알림 클릭 시간
- `failure_count`: 연속 발송 실패 횟수
- `last_failure_reason`: 마지막 실패 사유
### 상태 판정
- `healthy`: 최근 24시간 안에 실제 Push 수신 기록이 있음
- `watch`: 최근 7일 안에 수신 기록은 있지만 24시간을 초과함
- `stale`: 서버 발송 성공 기록은 있으나 장기간 실제 수신 기록이 없음
- `failed`: 연속 발송 실패가 3회 이상 누적됨
- `pending`: 등록은 되어 있으나 판단할 수 있는 수신/발송 기록이 부족함
### 자동 헬스체크
이 장치에는 systemd timer가 등록되어 있습니다.
```bash
control-push-healthcheck.timer
control-push-healthcheck.service
```
실행 명령은 다음과 같습니다.
```bash
/usr/bin/php /var/www/control/bin/push_healthcheck.php --min-hours=24
```
타이머는 활성화 후 10분 뒤 첫 실행하고, 이후 service 실행 기준 24시간마다 실행합니다. 스크립트 내부에서도 `--min-hours=24` 쿨다운을 확인하므로 중복 실행되더라도 24시간 안이면 발송을 건너뜁니다.
`silent: true`는 소리와 진동을 줄이는 옵션이며 완전히 보이지 않는 알림이 아닙니다. Web Push는 사용자에게 보이는 알림을 전제로 운영합니다.
## WiFi 관측 자동화 ## WiFi 관측 자동화
@@ -140,7 +94,7 @@ control-wifi-observe.service
/usr/bin/php /var/www/control/bin/wifi_observe.php /usr/bin/php /var/www/control/bin/wifi_observe.php
``` ```
이 작업은 팬 정책, 차트 로그 저장, Push 발송과 분리되어 있으며 WiFi client 목록 확인과 `wifi_observed_sessions` 갱신만 수행합니다. 이 작업은 팬 정책, 차트 로그 저장, HA 알림 발송과 분리되어 있으며 WiFi client 목록 확인과 `wifi_observed_sessions` 갱신만 수행합니다.
## 처리 흐름 ## 처리 흐름
@@ -150,14 +104,11 @@ control-wifi-observe.service
4. 팬 조작은 상태 저장, 정책 적용, 로그 저장 순서로 처리합니다. 4. 팬 조작은 상태 저장, 정책 적용, 로그 저장 순서로 처리합니다.
5. WiFi client 목록은 `iw station dump`와 dnsmasq lease를 조합하고, 5G 연결 시간이 `N/A`이면 MAC 기준 최초 감지 시간을 DB에 저장해 경과 시간을 계산합니다. 5. WiFi client 목록은 `iw station dump`와 dnsmasq lease를 조합하고, 5G 연결 시간이 `N/A`이면 MAC 기준 최초 감지 시간을 DB에 저장해 경과 시간을 계산합니다.
6. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G 관측 세션을 갱신합니다. 6. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G 관측 세션을 갱신합니다.
7. Push 등록은 권한 요청과 구독 저장이 끝나면 즉시 상태를 갱신합니다. 7. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다.
8. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다. 8. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다.
9. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다. 확인 단어 입력창에서 Enter를 눌러도 같은 Reboot 흐름이 다시 열리지 않도록 키 이벤트 기본 동작과 중복 실행을 차단합니다. 9. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다.
10. Translate 버튼은 최초 접속 시 디바이스 언어를 기본값으로 사용하고, 변경값은 `localStorage``controlLang`에 저장합니다. 라벨은 `Translate` 또는 `번역`으로만 표시합니다. 10. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다.
11. Theme 버튼은 기본값 `dark`로 시작하고, 변경값은 `localStorage``controlTheme`에 저장합니다. 라벨은 `Theme` 또는 `테마`로만 표시합니다. 11. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다.
12. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다. 웹 서버 실행 계정이 `/dev/vcio`를 직접 읽지 못하면 기존 sudo 실행 경로로 한 번 더 조회합니다.
13. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다. 현재 감지가 꺼진 뒤 5분 이내는 `복구 중`, 최근 10분에 episode가 2회 이상이면 `반복 발생`으로 표시합니다.
14. Push 발송, 수신, 표시, 클릭 이벤트를 기록하고 구독 DB의 건강 상태를 갱신합니다.
## 주요 함수/모듈 ## 주요 함수/모듈
@@ -165,14 +116,13 @@ control-wifi-observe.service
- `read_throttled_flags()`: `vcgencmd get_throttled`를 직접 조회하고, 권한 문제로 실패하면 sudo 경유 조회를 시도 - `read_throttled_flags()`: `vcgencmd get_throttled`를 직접 조회하고, 권한 문제로 실패하면 sudo 경유 조회를 시도
- `throttled_event_status()`: 저전압/스로틀링 episode 이력, 복구 중/반복 발생 판정, 최근 10분 통계를 계산 - `throttled_event_status()`: 저전압/스로틀링 episode 이력, 복구 중/반복 발생 판정, 최근 10분 통계를 계산
- `throttled_statuses()`: 라즈베리파이 throttled flag를 읽고 저전압/스로틀링 감지 이력을 상태 파일로 추적 - `throttled_statuses()`: 라즈베리파이 throttled flag를 읽고 저전압/스로틀링 감지 이력을 상태 파일로 추적
- `low_voltage_status()`: 기존 호출 호환을 위한 저전압 상태 wrapper
- `apply_fan_policy()`: 팬 목표값 계산과 적용 - `apply_fan_policy()`: 팬 목표값 계산과 적용
- `json_out()`: API JSON 응답 표준화 - `json_out()`: API JSON 응답 표준화
- `apply_observed_wifi_connected_time()`: 5G WiFi 연결 시간이 없는 client에 서버 관측 경과 시간 적용 - `apply_observed_wifi_connected_time()`: 5G WiFi 연결 시간이 없는 client에 서버 관측 경과 시간 적용
- `send_ha_notify()`: HA webhook 알림 발송, 서울 우선/목포 fallback, 성공/실패 로그 저장
- `ha_notify_due()`: 동일 tag 기준 쿨다운 판정
- `send_battery_low_notify_if_needed()`: 배터리 SOC 20% 이하 알림 발송
- `bin/wifi_observe.php`: 화면 접속 없이 `wifi_data()`를 호출해 5G 관측 세션을 선제 갱신 - `bin/wifi_observe.php`: 화면 접속 없이 `wifi_data()`를 호출해 5G 관측 세션을 선제 갱신
- `push_subscribe.js`: Push 구독 등록과 복구
- `push_health_summary()`: 상태별 Push 기기 수 집계
- `send_push_healthcheck_if_due()`: 쿨다운 기반 헬스체크 알림 발송
- `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어 - `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어
- `customAlert()`, `customConfirm()`, `customPrompt()`: 대시보드 공통 확인/입력 dialog - `customAlert()`, `customConfirm()`, `customPrompt()`: 대시보드 공통 확인/입력 dialog
- `controlLang`, `controlTheme`: 재접속 후에도 유지되는 언어/테마 localStorage key - `controlLang`, `controlTheme`: 재접속 후에도 유지되는 언어/테마 localStorage key
@@ -184,7 +134,8 @@ control-wifi-observe.service
- POST 조작 API는 CSRF 검증을 통과해야 합니다. - POST 조작 API는 CSRF 검증을 통과해야 합니다.
- sysfs 쓰기와 systemctl 실행 권한은 sudoers 범위로 제한해야 합니다. - sysfs 쓰기와 systemctl 실행 권한은 sudoers 범위로 제한해야 합니다.
- 재부팅 API는 CSRF, 로그인 세션, 확인 단어 `재부팅`, 관리자 암호 재검증을 모두 요구합니다. - 재부팅 API는 CSRF, 로그인 세션, 확인 단어 `재부팅`, 관리자 암호 재검증을 모두 요구합니다.
- VAPID key와 앱 비밀번호는 저장소 밖 secret 파일로 관리합니다. - 앱 비밀번호와 HA 알림 설정은 저장소 밖 secret 파일로 관리합니다.
- HA webhook은 외부 요청을 받으므로 webhook id는 저장소에 노출되는 값보다 `/home/seo/secret/control.php``ha_notify.webhook_id`로 교체해 운영할 수 있습니다.
## 운영 체크포인트 ## 운영 체크포인트
@@ -192,15 +143,12 @@ control-wifi-observe.service
- 배터리팩 변경 시 `/home/seo/secret/control.php``battery` 값을 먼저 갱신합니다. 현재 기준은 `2200mAh x 8 parallel = 17600mAh`, nominal `65.12Wh`입니다. - 배터리팩 변경 시 `/home/seo/secret/control.php``battery` 값을 먼저 갱신합니다. 현재 기준은 `2200mAh x 8 parallel = 17600mAh`, nominal `65.12Wh`입니다.
- 하드웨어 또는 OS 변경 후 fan sysfs 경로를 확인합니다. - 하드웨어 또는 OS 변경 후 fan sysfs 경로를 확인합니다.
- 2.4G 내장 WiFi는 `hostapd-24g.service` 개별 restart 직후 일부 IoT 단말이 WPA/EAPOL 재협상 루프에 들어갈 수 있으므로, 안정화된 상태에서는 개별 restart를 피하고 필요 시 전체 reboot 또는 채널 변경으로 재초기화합니다. - 2.4G 내장 WiFi는 `hostapd-24g.service` 개별 restart 직후 일부 IoT 단말이 WPA/EAPOL 재협상 루프에 들어갈 수 있으므로, 안정화된 상태에서는 개별 restart를 피하고 필요 시 전체 reboot 또는 채널 변경으로 재초기화합니다.
- 2026-06-07 조사 기준 `disassoc_low_ack=1`은 로그에 직접 원인으로 남지 않았고, 채널 변경 후 단말이 안정적으로 붙은 상태에서는 제거하지 않고 관찰합니다. 동일 단말이 짧게 재연결을 반복하면 그때 `disassoc_low_ack` 비활성화를 검토합니다.
- 2.4G WiFi 연결 시간은 내장 AP가 제공하는 `connected time`을 그대로 사용합니다. 2026-06-08 확인 기준 2.4G 연결 시간 누락은 없습니다.
- 5G WiFi 연결 시간은 외부 모듈이 값을 제공하지 않을 때 서버가 처음 감지한 시각 기준으로 계산합니다. 현재 5G 목록에서 MAC이 사라지면 관측 세션을 즉시 종료하므로 재연결 시 0부터 다시 누적됩니다. - 5G WiFi 연결 시간은 외부 모듈이 값을 제공하지 않을 때 서버가 처음 감지한 시각 기준으로 계산합니다. 현재 5G 목록에서 MAC이 사라지면 관측 세션을 즉시 종료하므로 재연결 시 0부터 다시 누적됩니다.
- `control-wifi-observe.timer`는 10초마다 `/var/www/control/bin/wifi_observe.php`를 실행해 사용자가 대시보드에 접속하지 않아도 5G 연결 시간 카운터를 시작하고 유지합니다. 상태는 `systemctl status control-wifi-observe.timer``/var/log/control-wifi-observe.log`에서 확인합니다. - `control-wifi-observe.timer`는 10초마다 `/var/www/control/bin/wifi_observe.php`를 실행해 사용자가 대시보드에 접속하지 않아도 5G 연결 시간 카운터를 시작하고 유지합니다.
- WebSocket은 장기 실행 프로세스이므로 `public/api.php`, `config/config.php`, `bin/control_ws.php` 변경을 감지하면 15초 안에 종료되고 `control-websocket.service`가 새 프로세스로 재시작합니다. - WebSocket은 장기 실행 프로세스이므로 `public/api.php`, `config/config.php`, `bin/control_ws.php` 변경을 감지하면 15초 안에 종료되고 `control-websocket.service`가 새 프로세스로 재시작합니다.
- WebSocket 장기 실행 중 DB 연결이 끊길 수 있으므로 reconnect 로그를 확인합니다. - WebSocket 장기 실행 중 DB 연결이 끊길 수 있으므로 reconnect 로그를 확인합니다.
- Push 구독 자동 복구가 과도한 반복 등록을 만들지 않는지 확인합니다. - HA 알림 자동화는 서울 `/home/seo/homeassistant/automations.yaml`, 목포 `/mnt/synology-docker/homeassistant/config/automations.yaml` 양쪽의 `CONTROL_HA_NOTIFY_WEBHOOK` 계열 항목에서 관리합니다.
- Push 헬스체크 timer 상태는 `systemctl list-timers --all control-push-healthcheck.timer`로 확인합니다. - 기존 `control-push-healthcheck.timer``control-push-healthcheck.service`는 사용하지 않습니다.
- `failed`, `stale` 기기는 알림 권한, 브라우저 데이터 삭제, PWA 재설치 여부를 확인합니다.
- Reboot API 사용 전 웹 서버 실행 계정의 sudoers에 `/usr/sbin/reboot` 비밀번호 없는 실행 권한이 제한적으로 설정되어 있는지 확인합니다. - Reboot API 사용 전 웹 서버 실행 계정의 sudoers에 `/usr/sbin/reboot` 비밀번호 없는 실행 권한이 제한적으로 설정되어 있는지 확인합니다.
- 저전압/스로틀링이 `N/A`로 보이면 웹 서버 실행 계정의 `/dev/vcio` 접근 권한과 `/usr/bin/vcgencmd get_throttled` sudoers 허용 여부를 확인합니다. - 저전압/스로틀링이 `N/A`로 보이면 웹 서버 실행 계정의 `/dev/vcio` 접근 권한과 `/usr/bin/vcgencmd get_throttled` sudoers 허용 여부를 확인합니다.
- 언어/테마 표시가 예상과 다르면 브라우저 localStorage의 `controlLang`, `controlTheme` 값을 확인합니다. - 언어/테마 표시가 예상과 다르면 브라우저 localStorage의 `controlLang`, `controlTheme` 값을 확인합니다.
+1 -1
View File
@@ -240,7 +240,7 @@ try {
'hostname' => gethostname() ?: null, 'hostname' => gethostname() ?: null,
]); ]);
send_battery_low_push_if_needed($battery); send_battery_low_notify_if_needed($battery);
if (!$ok) { if (!$ok) {
add_fan_action( add_fan_action(
-26
View File
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../config/config.php';
$minHours = 24;
$force = false;
foreach (array_slice($argv, 1) as $arg) {
if ($arg === '--force') {
$force = true;
continue;
}
if (str_starts_with($arg, '--min-hours=')) {
$minHours = (int)substr($arg, 12);
}
}
$result = send_push_healthcheck_if_due($minHours, $force);
echo json_encode([
'ok' => true,
'result' => $result,
'summary' => push_health_summary(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL;
-1
View File
@@ -1,6 +1,5 @@
{ {
"require": { "require": {
"minishlink/web-push": "^10.0",
"cboden/ratchet": "^0.4", "cboden/ratchet": "^0.4",
"react/event-loop": "^1.5" "react/event-loop": "^1.5"
} }
Generated
+12 -714
View File
@@ -6,65 +6,6 @@
], ],
"content-hash": "883a7a92feed43973abe8d0f61398105", "content-hash": "883a7a92feed43973abe8d0f61398105",
"packages": [ "packages": [
{
"name": "brick/math",
"version": "0.17.1",
"source": {
"type": "git",
"url": "https://github.com/brick/math.git",
"reference": "6aef71a9fbbd1ee7be0e313cd627f8e6f7125a5b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/brick/math/zipball/6aef71a9fbbd1ee7be0e313cd627f8e6f7125a5b",
"reference": "6aef71a9fbbd1ee7be0e313cd627f8e6f7125a5b",
"shasum": ""
},
"require": {
"php": "^8.2"
},
"require-dev": {
"phpstan/phpstan": "2.1.22",
"phpunit/phpunit": "^11.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Brick\\Math\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Arbitrary-precision arithmetic library",
"keywords": [
"Arbitrary-precision",
"BigInteger",
"BigRational",
"arithmetic",
"bigdecimal",
"bignum",
"bignumber",
"brick",
"decimal",
"integer",
"math",
"mathematics",
"rational"
],
"support": {
"issues": "https://github.com/brick/math/issues",
"source": "https://github.com/brick/math/tree/0.17.1"
},
"funding": [
{
"url": "https://github.com/BenMorel",
"type": "github"
}
],
"time": "2026-04-19T20:55:20+00:00"
},
{ {
"name": "cboden/ratchet", "name": "cboden/ratchet",
"version": "v0.4.4", "version": "v0.4.4",
@@ -164,7 +105,7 @@
"email": "igor@wiedler.ch" "email": "igor@wiedler.ch"
} }
], ],
"description": "Événement is a very simple event dispatching library for PHP", "description": "\u00c9v\u00e9nement is a very simple event dispatching library for PHP",
"keywords": [ "keywords": [
"event-dispatcher", "event-dispatcher",
"event-emitter" "event-emitter"
@@ -175,215 +116,6 @@
}, },
"time": "2023-08-08T05:53:35+00:00" "time": "2023-08-08T05:53:35+00:00"
}, },
{
"name": "guzzlehttp/guzzle",
"version": "7.10.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
"reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/promises": "^2.3",
"guzzlehttp/psr7": "^2.8",
"php": "^7.2.5 || ^8.0",
"psr/http-client": "^1.0",
"symfony/deprecation-contracts": "^2.2 || ^3.0"
},
"provide": {
"psr/http-client-implementation": "1.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*",
"guzzle/client-integration-tests": "3.0.2",
"php-http/message-factory": "^1.1",
"phpunit/phpunit": "^8.5.39 || ^9.6.20",
"psr/log": "^1.1 || ^2.0 || ^3.0"
},
"suggest": {
"ext-curl": "Required for CURL handler support",
"ext-intl": "Required for Internationalized Domain Name (IDN) support",
"psr/log": "Required for using the Log middleware"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"files": [
"src/functions_include.php"
],
"psr-4": {
"GuzzleHttp\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Jeremy Lindblom",
"email": "jeremeamia@gmail.com",
"homepage": "https://github.com/jeremeamia"
},
{
"name": "George Mponos",
"email": "gmponos@gmail.com",
"homepage": "https://github.com/gmponos"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://github.com/sagikazarmark"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
}
],
"description": "Guzzle is a PHP HTTP client library",
"keywords": [
"client",
"curl",
"framework",
"http",
"http client",
"psr-18",
"psr-7",
"rest",
"web service"
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
"source": "https://github.com/guzzle/guzzle/tree/7.10.0"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
"type": "tidelift"
}
],
"time": "2025-08-23T22:36:01+00:00"
},
{
"name": "guzzlehttp/promises",
"version": "2.3.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
"reference": "481557b130ef3790cf82b713667b43030dc9c957"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957",
"reference": "481557b130ef3790cf82b713667b43030dc9c957",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.44 || ^9.6.25"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Promise\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
}
],
"description": "Guzzle promises library",
"keywords": [
"promise"
],
"support": {
"issues": "https://github.com/guzzle/promises/issues",
"source": "https://github.com/guzzle/promises/tree/2.3.0"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
"type": "tidelift"
}
],
"time": "2025-08-22T14:34:08+00:00"
},
{ {
"name": "guzzlehttp/psr7", "name": "guzzlehttp/psr7",
"version": "2.9.0", "version": "2.9.0",
@@ -455,7 +187,7 @@
"homepage": "https://github.com/Nyholm" "homepage": "https://github.com/Nyholm"
}, },
{ {
"name": "Márk Sági-Kazár", "name": "M\u00e1rk S\u00e1gi-Kaz\u00e1r",
"email": "mark.sagikazar@gmail.com", "email": "mark.sagikazar@gmail.com",
"homepage": "https://github.com/sagikazarmark" "homepage": "https://github.com/sagikazarmark"
}, },
@@ -465,7 +197,7 @@
"homepage": "https://github.com/Tobion" "homepage": "https://github.com/Tobion"
}, },
{ {
"name": "Márk Sági-Kazár", "name": "M\u00e1rk S\u00e1gi-Kaz\u00e1r",
"email": "mark.sagikazar@gmail.com", "email": "mark.sagikazar@gmail.com",
"homepage": "https://sagikazarmark.hu" "homepage": "https://sagikazarmark.hu"
} }
@@ -501,176 +233,6 @@
], ],
"time": "2026-03-10T16:41:02+00:00" "time": "2026-03-10T16:41:02+00:00"
}, },
{
"name": "minishlink/web-push",
"version": "v10.0.3",
"source": {
"type": "git",
"url": "https://github.com/web-push-libs/web-push-php.git",
"reference": "547695eb42b062517fc604c85d6f7bb8174d31b0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/547695eb42b062517fc604c85d6f7bb8174d31b0",
"reference": "547695eb42b062517fc604c85d6f7bb8174d31b0",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-openssl": "*",
"guzzlehttp/guzzle": "^7.9.2",
"php": ">=8.2",
"spomky-labs/base64url": "^2.0.4",
"symfony/polyfill-php83": "^1.33",
"web-token/jwt-library": "^3.4.9|^4.0.6"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^v3.92.2",
"phpstan/phpstan": "^2.1.33",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^11.5.46|^12.5.2",
"symfony/polyfill-iconv": "^1.33"
},
"suggest": {
"ext-bcmath": "Optional for performance.",
"ext-gmp": "Optional for performance."
},
"type": "library",
"autoload": {
"psr-4": {
"Minishlink\\WebPush\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Louis Lagrange",
"email": "lagrange.louis@gmail.com",
"homepage": "https://github.com/Minishlink"
}
],
"description": "Web Push library for PHP",
"homepage": "https://github.com/web-push-libs/web-push-php",
"keywords": [
"Push API",
"WebPush",
"notifications",
"push",
"web"
],
"support": {
"issues": "https://github.com/web-push-libs/web-push-php/issues",
"source": "https://github.com/web-push-libs/web-push-php/tree/v10.0.3"
},
"time": "2026-03-09T23:16:02+00:00"
},
{
"name": "psr/clock",
"version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/clock.git",
"reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d",
"reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d",
"shasum": ""
},
"require": {
"php": "^7.0 || ^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Psr\\Clock\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for reading the clock.",
"homepage": "https://github.com/php-fig/clock",
"keywords": [
"clock",
"now",
"psr",
"psr-20",
"time"
],
"support": {
"issues": "https://github.com/php-fig/clock/issues",
"source": "https://github.com/php-fig/clock/tree/1.0.0"
},
"time": "2022-11-25T14:36:26+00:00"
},
{
"name": "psr/http-client",
"version": "1.0.3",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-client.git",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
"shasum": ""
},
"require": {
"php": "^7.0 || ^8.0",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP clients",
"homepage": "https://github.com/php-fig/http-client",
"keywords": [
"http",
"http-client",
"psr",
"psr-18"
],
"support": {
"source": "https://github.com/php-fig/http-client"
},
"time": "2023-09-23T14:17:50+00:00"
},
{ {
"name": "psr/http-factory", "name": "psr/http-factory",
"version": "1.1.0", "version": "1.1.0",
@@ -913,7 +475,7 @@
], ],
"authors": [ "authors": [
{ {
"name": "Christian Lück", "name": "Christian L\u00fcck",
"email": "christian@clue.engineering", "email": "christian@clue.engineering",
"homepage": "https://clue.engineering/" "homepage": "https://clue.engineering/"
}, },
@@ -989,7 +551,7 @@
], ],
"authors": [ "authors": [
{ {
"name": "Christian Lück", "name": "Christian L\u00fcck",
"email": "christian@clue.engineering", "email": "christian@clue.engineering",
"homepage": "https://clue.engineering/" "homepage": "https://clue.engineering/"
}, },
@@ -1063,7 +625,7 @@
], ],
"authors": [ "authors": [
{ {
"name": "Christian Lück", "name": "Christian L\u00fcck",
"email": "christian@clue.engineering", "email": "christian@clue.engineering",
"homepage": "https://clue.engineering/" "homepage": "https://clue.engineering/"
}, },
@@ -1141,7 +703,7 @@
"homepage": "https://sorgalla.com/" "homepage": "https://sorgalla.com/"
}, },
{ {
"name": "Christian Lück", "name": "Christian L\u00fcck",
"email": "christian@clue.engineering", "email": "christian@clue.engineering",
"homepage": "https://clue.engineering/" "homepage": "https://clue.engineering/"
}, },
@@ -1213,7 +775,7 @@
], ],
"authors": [ "authors": [
{ {
"name": "Christian Lück", "name": "Christian L\u00fcck",
"email": "christian@clue.engineering", "email": "christian@clue.engineering",
"homepage": "https://clue.engineering/" "homepage": "https://clue.engineering/"
}, },
@@ -1288,7 +850,7 @@
], ],
"authors": [ "authors": [
{ {
"name": "Christian Lück", "name": "Christian L\u00fcck",
"email": "christian@clue.engineering", "email": "christian@clue.engineering",
"homepage": "https://clue.engineering/" "homepage": "https://clue.engineering/"
}, },
@@ -1331,181 +893,6 @@
], ],
"time": "2024-06-11T12:45:25+00:00" "time": "2024-06-11T12:45:25+00:00"
}, },
{
"name": "spomky-labs/base64url",
"version": "v2.0.4",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/base64url.git",
"reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d",
"reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d",
"shasum": ""
},
"require": {
"php": ">=7.1"
},
"require-dev": {
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "^0.11|^0.12",
"phpstan/phpstan-beberlei-assert": "^0.11|^0.12",
"phpstan/phpstan-deprecation-rules": "^0.11|^0.12",
"phpstan/phpstan-phpunit": "^0.11|^0.12",
"phpstan/phpstan-strict-rules": "^0.11|^0.12"
},
"type": "library",
"autoload": {
"psr-4": {
"Base64Url\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky-Labs/base64url/contributors"
}
],
"description": "Base 64 URL Safe Encoding/Decoding PHP Library",
"homepage": "https://github.com/Spomky-Labs/base64url",
"keywords": [
"base64",
"rfc4648",
"safe",
"url"
],
"support": {
"issues": "https://github.com/Spomky-Labs/base64url/issues",
"source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2020-11-03T09:10:25+00:00"
},
{
"name": "spomky-labs/pki-framework",
"version": "1.4.2",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/pki-framework.git",
"reference": "aa576cbd07128075bef97ac2f8af9854e67513d8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8",
"reference": "aa576cbd07128075bef97ac2f8af9854e67513d8",
"shasum": ""
},
"require": {
"brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
"ext-mbstring": "*",
"php": ">=8.1",
"psr/clock": "^1.0"
},
"require-dev": {
"ekino/phpstan-banned-code": "^1.0|^2.0|^3.0",
"ext-gmp": "*",
"ext-openssl": "*",
"infection/infection": "^0.28|^0.29|^0.31|^0.32",
"php-parallel-lint/php-parallel-lint": "^1.3",
"phpstan/extension-installer": "^1.3|^2.0",
"phpstan/phpstan": "^1.8|^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0|^2.0",
"phpstan/phpstan-phpunit": "^1.1|^2.0",
"phpstan/phpstan-strict-rules": "^1.3|^2.0",
"phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0",
"rector/rector": "^1.0|^2.0",
"roave/security-advisories": "dev-latest",
"symfony/string": "^6.4|^7.0|^8.0",
"symfony/var-dumper": "^6.4|^7.0|^8.0",
"symplify/easy-coding-standard": "^12.0|^13.0"
},
"suggest": {
"ext-bcmath": "For better performance (or GMP)",
"ext-gmp": "For better performance (or BCMath)",
"ext-openssl": "For OpenSSL based cyphering"
},
"type": "library",
"autoload": {
"psr-4": {
"SpomkyLabs\\Pki\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Joni Eskelinen",
"email": "jonieske@gmail.com",
"role": "Original developer"
},
{
"name": "Florent Morselli",
"email": "florent.morselli@spomky-labs.com",
"role": "Spomky-Labs PKI Framework developer"
}
],
"description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.",
"homepage": "https://github.com/spomky-labs/pki-framework",
"keywords": [
"DER",
"Private Key",
"ac",
"algorithm identifier",
"asn.1",
"asn1",
"attribute certificate",
"certificate",
"certification request",
"cryptography",
"csr",
"decrypt",
"ec",
"encrypt",
"pem",
"pkcs",
"public key",
"rsa",
"sign",
"signature",
"verify",
"x.509",
"x.690",
"x509",
"x690"
],
"support": {
"issues": "https://github.com/Spomky-Labs/pki-framework/issues",
"source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2026-03-23T22:56:56+00:00"
},
{ {
"name": "symfony/deprecation-contracts", "name": "symfony/deprecation-contracts",
"version": "v3.7.0", "version": "v3.7.0",
@@ -1909,104 +1296,15 @@
} }
], ],
"time": "2026-05-24T11:18:16+00:00" "time": "2026-05-24T11:18:16+00:00"
},
{
"name": "web-token/jwt-library",
"version": "4.1.6",
"source": {
"type": "git",
"url": "https://github.com/web-token/jwt-library.git",
"reference": "e8ab00927a3856f3f0c8218226382cd6a58928a1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-token/jwt-library/zipball/e8ab00927a3856f3f0c8218226382cd6a58928a1",
"reference": "e8ab00927a3856f3f0c8218226382cd6a58928a1",
"shasum": ""
},
"require": {
"brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
"php": ">=8.2",
"psr/clock": "^1.0",
"spomky-labs/pki-framework": "^1.2.1"
},
"conflict": {
"spomky-labs/jose": "*"
},
"suggest": {
"ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance",
"ext-gmp": "GMP or BCMath is highly recommended to improve the library performance",
"ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)",
"ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys",
"paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys",
"spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)",
"symfony/console": "Needed to use console commands",
"symfony/http-client": "To enable JKU/X5U support."
},
"type": "library",
"autoload": {
"psr-4": {
"Jose\\Component\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/web-token/jwt-framework/contributors"
}
],
"description": "JWT library",
"homepage": "https://github.com/web-token",
"keywords": [
"JOSE",
"JWE",
"JWK",
"JWKSet",
"JWS",
"Jot",
"RFC7515",
"RFC7516",
"RFC7517",
"RFC7518",
"RFC7519",
"RFC7520",
"bundle",
"jwa",
"jwt",
"symfony"
],
"support": {
"issues": "https://github.com/web-token/jwt-library/issues",
"source": "https://github.com/web-token/jwt-library/tree/4.1.6"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2026-04-14T07:44:20+00:00"
} }
], ],
"packages-dev": [], "packages-dev": [],
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "stable",
"stability-flags": {}, "stability-flags": [],
"prefer-stable": false, "prefer-stable": false,
"prefer-lowest": false, "prefer-lowest": false,
"platform": {}, "platform": [],
"platform-dev": {}, "platform-dev": [],
"plugin-api-version": "2.9.0" "plugin-api-version": "2.9.0"
} }
+165 -631
View File
@@ -14,6 +14,7 @@ if (!is_file($controlSecretFile)) {
$controlSecretConfig = require $controlSecretFile; $controlSecretConfig = require $controlSecretFile;
$controlDbConfig = is_array($controlSecretConfig['db'] ?? null) ? $controlSecretConfig['db'] : []; $controlDbConfig = is_array($controlSecretConfig['db'] ?? null) ? $controlSecretConfig['db'] : [];
$controlBatteryConfig = is_array($controlSecretConfig['battery'] ?? null) ? $controlSecretConfig['battery'] : []; $controlBatteryConfig = is_array($controlSecretConfig['battery'] ?? null) ? $controlSecretConfig['battery'] : [];
$controlHaNotifyConfig = is_array($controlSecretConfig['ha_notify'] ?? null) ? $controlSecretConfig['ha_notify'] : [];
define('APP_PASSWORD', (string)($controlSecretConfig['app_password'] ?? '')); define('APP_PASSWORD', (string)($controlSecretConfig['app_password'] ?? ''));
define('DB_HOST', (string)($controlDbConfig['host'] ?? '127.0.0.1')); define('DB_HOST', (string)($controlDbConfig['host'] ?? '127.0.0.1'));
@@ -21,6 +22,7 @@ define('DB_PORT', (int)($controlDbConfig['port'] ?? 3306));
define('DB_NAME', (string)($controlDbConfig['name'] ?? 'fanpanel')); define('DB_NAME', (string)($controlDbConfig['name'] ?? 'fanpanel'));
define('DB_USER', (string)($controlDbConfig['user'] ?? 'fanpanel')); define('DB_USER', (string)($controlDbConfig['user'] ?? 'fanpanel'));
define('DB_PASS', (string)($controlDbConfig['pass'] ?? '')); define('DB_PASS', (string)($controlDbConfig['pass'] ?? ''));
define('HA_NOTIFY_WEBHOOK_ID', (string)($controlHaNotifyConfig['webhook_id'] ?? 'control_notify_20260621'));
$batteryCellCapacityMah = max(0.0, (float)($controlBatteryConfig['cell_capacity_mah'] ?? 0.0)); $batteryCellCapacityMah = max(0.0, (float)($controlBatteryConfig['cell_capacity_mah'] ?? 0.0));
$batteryParallelCells = max(1, (int)($controlBatteryConfig['parallel_cells'] ?? 1)); $batteryParallelCells = max(1, (int)($controlBatteryConfig['parallel_cells'] ?? 1));
@@ -35,10 +37,6 @@ define('BATTERY_CAPACITY_WH', isset($controlBatteryConfig['capacity_wh']) ? max(
date_default_timezone_set('Asia/Seoul'); date_default_timezone_set('Asia/Seoul');
if (is_file(__DIR__ . '/vapid.php')) {
require_once __DIR__ . '/vapid.php';
}
if (is_file(__DIR__ . '/../vendor/autoload.php')) { if (is_file(__DIR__ . '/../vendor/autoload.php')) {
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
} }
@@ -273,55 +271,22 @@ function bootstrap_db(): void
"); ");
$pdo->exec(" $pdo->exec("
CREATE TABLE IF NOT EXISTS push_subscriptions ( CREATE TABLE IF NOT EXISTS ha_notify_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
endpoint_hash CHAR(64) NOT NULL,
endpoint TEXT NOT NULL,
p256dh VARCHAR(255) NOT NULL,
auth VARCHAR(255) NOT NULL,
content_encoding VARCHAR(32) NOT NULL DEFAULT 'aes128gcm',
device_name VARCHAR(64) NULL,
user_agent VARCHAR(255) NULL,
actor_ip VARCHAR(64) NULL,
last_send_success_at DATETIME NULL,
last_send_failed_at DATETIME NULL,
last_received_at DATETIME NULL,
last_notification_at DATETIME NULL,
last_click_at DATETIME NULL,
failure_count INT UNSIGNED NOT NULL DEFAULT 0,
last_failure_reason VARCHAR(255) NULL,
UNIQUE KEY uniq_endpoint_hash (endpoint_hash),
INDEX idx_last_seen_at (last_seen_at),
INDEX idx_last_received_at (last_received_at),
INDEX idx_last_send_success_at (last_send_success_at)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS push_event_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
event VARCHAR(32) NOT NULL, event VARCHAR(32) NOT NULL,
endpoint_hash CHAR(64) NULL, target VARCHAR(16) NULL,
device_name VARCHAR(64) NULL, title VARCHAR(255) NULL,
endpoint_host VARCHAR(128) NULL, tag VARCHAR(128) NULL,
actor_ip VARCHAR(64) NULL, actor_ip VARCHAR(64) NULL,
user_agent VARCHAR(255) NULL, http_code INT NULL,
message VARCHAR(255) NULL, error VARCHAR(255) NULL,
meta JSON NULL, meta JSON NULL,
INDEX idx_created_at (created_at), INDEX idx_created_at (created_at),
INDEX idx_event (event), INDEX idx_event (event),
INDEX idx_endpoint_hash (endpoint_hash) INDEX idx_tag (tag)
) ENGINE=InnoDB ) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4 DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci COLLATE=utf8mb4_unicode_ci
@@ -349,19 +314,6 @@ function bootstrap_db(): void
COLLATE=utf8mb4_unicode_ci COLLATE=utf8mb4_unicode_ci
"); ");
$pdo->exec("
CREATE TABLE IF NOT EXISTS battery_push_state (
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
last_sent_at DATETIME NULL,
last_percent DECIMAL(6,2) NULL,
last_voltage DECIMAL(6,3) NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
");
$pdo->exec(" $pdo->exec("
INSERT IGNORE INTO control_state INSERT IGNORE INTO control_state
(id, mode, manual_pwm) (id, mode, manual_pwm)
@@ -388,16 +340,6 @@ function bootstrap_db(): void
"ALTER TABLE system_notice_state ADD COLUMN active_temp_delta DECIMAL(8,2) NULL AFTER active_reason", "ALTER TABLE system_notice_state ADD COLUMN active_temp_delta DECIMAL(8,2) NULL AFTER active_reason",
"ALTER TABLE system_notice_state ADD COLUMN active_rpm_delta DECIMAL(12,2) NULL AFTER active_temp_delta", "ALTER TABLE system_notice_state ADD COLUMN active_rpm_delta DECIMAL(12,2) NULL AFTER active_temp_delta",
"ALTER TABLE system_notice_state ADD COLUMN process_signature VARCHAR(255) NULL AFTER active_rpm_delta", "ALTER TABLE system_notice_state ADD COLUMN process_signature VARCHAR(255) NULL AFTER active_rpm_delta",
"ALTER TABLE push_subscriptions ADD COLUMN device_name VARCHAR(64) NULL AFTER content_encoding",
"ALTER TABLE push_subscriptions ADD COLUMN last_send_success_at DATETIME NULL AFTER actor_ip",
"ALTER TABLE push_subscriptions ADD COLUMN last_send_failed_at DATETIME NULL AFTER last_send_success_at",
"ALTER TABLE push_subscriptions ADD COLUMN last_received_at DATETIME NULL AFTER last_send_failed_at",
"ALTER TABLE push_subscriptions ADD COLUMN last_notification_at DATETIME NULL AFTER last_received_at",
"ALTER TABLE push_subscriptions ADD COLUMN last_click_at DATETIME NULL AFTER last_notification_at",
"ALTER TABLE push_subscriptions ADD COLUMN failure_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER last_click_at",
"ALTER TABLE push_subscriptions ADD COLUMN last_failure_reason VARCHAR(255) NULL AFTER failure_count",
"ALTER TABLE push_subscriptions ADD INDEX idx_last_received_at (last_received_at)",
"ALTER TABLE push_subscriptions ADD INDEX idx_last_send_success_at (last_send_success_at)",
"ALTER TABLE sensor_logs DROP COLUMN disk_total_gb", "ALTER TABLE sensor_logs DROP COLUMN disk_total_gb",
"ALTER TABLE sensor_logs DROP COLUMN disk_used_gb", "ALTER TABLE sensor_logs DROP COLUMN disk_used_gb",
"ALTER TABLE sensor_logs DROP COLUMN disk_free_gb", "ALTER TABLE sensor_logs DROP COLUMN disk_free_gb",
@@ -489,596 +431,191 @@ function json_out(array $payload, int $status = 200): never
exit; exit;
} }
function vapid_public_key(): string function ha_notify_targets(): array
{ {
return defined('VAPID_PUBLIC_KEY') ? VAPID_PUBLIC_KEY : ''; global $controlHaNotifyConfig;
$configured = is_array($controlHaNotifyConfig['targets'] ?? null) ? $controlHaNotifyConfig['targets'] : [];
if ($configured !== []) {
return $configured;
}
return [
['name' => 'seoul', 'base' => 'https://ha.seoul.chaegeon.com'],
['name' => 'main', 'base' => 'https://ha.chaegeon.com'],
];
} }
function push_subscription_from_json(string $raw): array function ha_notify_log(string $event, array $context = []): void
{
$data = json_decode($raw, true);
return is_array($data) ? $data : [];
}
function push_log_event(string $event, array $context = []): void
{ {
try { try {
$endpoint = (string)($context['endpoint'] ?? '');
$endpointHash = (string)($context['endpoint_hash'] ?? '');
if ($endpointHash === '' && $endpoint !== '') {
$endpointHash = hash('sha256', $endpoint);
}
$stmt = db()->prepare(" $stmt = db()->prepare("
INSERT INTO push_event_logs ( INSERT INTO ha_notify_logs (
event, event,
endpoint_hash, target,
device_name, title,
endpoint_host, tag,
actor_ip, actor_ip,
user_agent, http_code,
message, error,
meta meta
) VALUES ( ) VALUES (
:event, :event,
:endpoint_hash, :target,
:device_name, :title,
:endpoint_host, :tag,
:actor_ip, :actor_ip,
:user_agent, :http_code,
:message, :error,
:meta :meta
) )
"); ");
unset($context['endpoint'], $context['p256dh'], $context['auth']);
$stmt->execute([ $stmt->execute([
':event' => mb_substr($event, 0, 32), ':event' => mb_substr($event, 0, 32),
':endpoint_hash' => $endpointHash !== '' ? $endpointHash : null, ':target' => isset($context['target']) ? mb_substr((string)$context['target'], 0, 16) : null,
':device_name' => isset($context['device_name']) ? mb_substr((string)$context['device_name'], 0, 64) : null, ':title' => isset($context['title']) ? mb_substr((string)$context['title'], 0, 255) : null,
':endpoint_host' => $endpoint !== '' ? mb_substr((string)(parse_url($endpoint, PHP_URL_HOST) ?: ''), 0, 128) : null, ':tag' => isset($context['tag']) ? mb_substr((string)$context['tag'], 0, 128) : null,
':actor_ip' => mb_substr((string)($_SERVER['REMOTE_ADDR'] ?? ''), 0, 64), ':actor_ip' => mb_substr((string)($_SERVER['REMOTE_ADDR'] ?? ''), 0, 64),
':user_agent' => mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), ':http_code' => isset($context['http_code']) ? (int)$context['http_code'] : null,
':message' => isset($context['message']) ? mb_substr((string)$context['message'], 0, 255) : null, ':error' => isset($context['error']) ? mb_substr((string)$context['error'], 0, 255) : null,
':meta' => json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ':meta' => json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]); ]);
update_push_subscription_health($event, $endpointHash, $context);
} catch (Throwable) { } catch (Throwable) {
} }
} }
function update_push_subscription_health(string $event, string $endpointHash, array $context = []): void function latest_ha_notify_epoch_by_tag(string $tag): int
{
if (!preg_match('/^[a-f0-9]{64}$/', $endpointHash)) {
return;
}
$reason = mb_substr((string)($context['reason'] ?? ''), 0, 255);
try {
if (in_array($event, ['register', 'register_update'], true)) {
$stmt = db()->prepare("
UPDATE push_subscriptions
SET last_seen_at = CURRENT_TIMESTAMP,
failure_count = 0,
last_failure_reason = NULL
WHERE endpoint_hash = :endpoint_hash
");
$stmt->execute([':endpoint_hash' => $endpointHash]);
return;
}
if ($event === 'send_success') {
$stmt = db()->prepare("
UPDATE push_subscriptions
SET last_send_success_at = CURRENT_TIMESTAMP,
failure_count = 0,
last_failure_reason = NULL
WHERE endpoint_hash = :endpoint_hash
");
$stmt->execute([':endpoint_hash' => $endpointHash]);
return;
}
if ($event === 'send_failed') {
$stmt = db()->prepare("
UPDATE push_subscriptions
SET last_send_failed_at = CURRENT_TIMESTAMP,
failure_count = failure_count + 1,
last_failure_reason = :reason
WHERE endpoint_hash = :endpoint_hash
");
$stmt->execute([
':endpoint_hash' => $endpointHash,
':reason' => $reason !== '' ? $reason : 'send_failed',
]);
return;
}
$columnByEvent = [
'push_received' => 'last_received_at',
'notification_shown' => 'last_notification_at',
'notification_click' => 'last_click_at',
];
if (isset($columnByEvent[$event])) {
$column = $columnByEvent[$event];
$stmt = db()->prepare("
UPDATE push_subscriptions
SET {$column} = CURRENT_TIMESTAMP,
failure_count = 0,
last_failure_reason = NULL
WHERE endpoint_hash = :endpoint_hash
");
$stmt->execute([':endpoint_hash' => $endpointHash]);
}
} catch (Throwable) {
}
}
function save_push_subscription(array $subscription): void
{
$endpoint = (string)($subscription['endpoint'] ?? '');
$keys = is_array($subscription['keys'] ?? null) ? $subscription['keys'] : [];
$p256dh = (string)($keys['p256dh'] ?? '');
$auth = (string)($keys['auth'] ?? '');
$deviceName = trim((string)($subscription['device_name'] ?? ''));
preg_match_all('/[\x{AC00}-\x{D7A3}\x{3131}-\x{318E}]/u', $deviceName, $hangulMatches);
$hangulCount = count($hangulMatches[0] ?? []);
if ($endpoint === '' || $p256dh === '' || $auth === '') {
json_out([
'ok' => false,
'error' => 'invalid_subscription',
], 422);
}
if ($deviceName === '' || $hangulCount < 2) {
json_out([
'ok' => false,
'error' => 'invalid_device_name',
'message' => '기기 이름은 한글 2글자 이상이어야 합니다.',
], 422);
}
$endpointHash = hash('sha256', $endpoint);
$exists = false;
try {
$check = db()->prepare("SELECT 1 FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash LIMIT 1");
$check->execute([':endpoint_hash' => $endpointHash]);
$exists = (bool)$check->fetchColumn();
} catch (Throwable) {
}
$stmt = db()->prepare("
INSERT INTO push_subscriptions (
endpoint_hash,
endpoint,
p256dh,
auth,
content_encoding,
device_name,
user_agent,
actor_ip
) VALUES (
:endpoint_hash,
:endpoint,
:p256dh,
:auth,
:content_encoding,
:device_name,
:user_agent,
:actor_ip
)
ON DUPLICATE KEY UPDATE
endpoint = VALUES(endpoint),
p256dh = VALUES(p256dh),
auth = VALUES(auth),
content_encoding = VALUES(content_encoding),
device_name = VALUES(device_name),
user_agent = VALUES(user_agent),
actor_ip = VALUES(actor_ip),
last_seen_at = CURRENT_TIMESTAMP
");
$stmt->execute([
':endpoint_hash' => $endpointHash,
':endpoint' => $endpoint,
':p256dh' => $p256dh,
':auth' => $auth,
':content_encoding' => (string)($subscription['contentEncoding'] ?? 'aes128gcm'),
':device_name' => mb_substr($deviceName, 0, 64),
':user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
':actor_ip' => $_SERVER['REMOTE_ADDR'] ?? null,
]);
push_log_event($exists ? 'register_update' : 'register', [
'endpoint' => $endpoint,
'device_name' => $deviceName,
'content_encoding' => (string)($subscription['contentEncoding'] ?? 'aes128gcm'),
]);
}
function push_device_rows(): array
{
$stmt = db()->query("
SELECT
endpoint_hash,
endpoint,
device_name,
content_encoding,
user_agent,
actor_ip,
created_at,
last_seen_at,
last_send_success_at,
last_send_failed_at,
last_received_at,
last_notification_at,
last_click_at,
failure_count,
last_failure_reason,
TIMESTAMPDIFF(SECOND, last_seen_at, NOW()) AS last_seen_seconds,
TIMESTAMPDIFF(SECOND, last_send_success_at, NOW()) AS last_send_success_seconds,
TIMESTAMPDIFF(SECOND, last_received_at, NOW()) AS last_received_seconds,
TIMESTAMPDIFF(SECOND, last_notification_at, NOW()) AS last_notification_seconds
FROM push_subscriptions
ORDER BY last_seen_at DESC
LIMIT 200
");
$rows = [];
foreach ($stmt->fetchAll() as $row) {
$endpoint = (string)($row['endpoint'] ?? '');
$failureCount = (int)($row['failure_count'] ?? 0);
$lastReceivedSeconds = isset($row['last_received_seconds']) ? (int)$row['last_received_seconds'] : null;
$lastSendSuccessSeconds = isset($row['last_send_success_seconds']) ? (int)$row['last_send_success_seconds'] : null;
$status = 'pending';
$statusText = '수신 대기';
if ($failureCount >= 3) {
$status = 'failed';
$statusText = '발송 실패 누적';
} elseif ($lastReceivedSeconds !== null && $lastReceivedSeconds <= 86400) {
$status = 'healthy';
$statusText = '정상';
} elseif ($lastReceivedSeconds !== null && $lastReceivedSeconds <= 604800) {
$status = 'watch';
$statusText = '수신 지연';
} elseif ($lastSendSuccessSeconds !== null) {
$status = 'stale';
$statusText = '장기 미수신';
}
$rows[] = [
'hash' => (string)($row['endpoint_hash'] ?? hash('sha256', $endpoint)),
'endpoint' => $endpoint,
'host' => parse_url($endpoint, PHP_URL_HOST) ?: 'unknown',
'device_name' => (string)($row['device_name'] ?? ''),
'content_encoding' => (string)($row['content_encoding'] ?? ''),
'user_agent' => (string)($row['user_agent'] ?? ''),
'actor_ip' => (string)($row['actor_ip'] ?? ''),
'created_at' => (string)($row['created_at'] ?? ''),
'last_seen_at' => (string)($row['last_seen_at'] ?? ''),
'last_send_success_at' => (string)($row['last_send_success_at'] ?? ''),
'last_send_failed_at' => (string)($row['last_send_failed_at'] ?? ''),
'last_received_at' => (string)($row['last_received_at'] ?? ''),
'last_notification_at' => (string)($row['last_notification_at'] ?? ''),
'last_click_at' => (string)($row['last_click_at'] ?? ''),
'last_seen_seconds' => $row['last_seen_seconds'] !== null ? (int)$row['last_seen_seconds'] : null,
'last_send_success_seconds' => $lastSendSuccessSeconds,
'last_received_seconds' => $lastReceivedSeconds,
'last_notification_seconds' => $row['last_notification_seconds'] !== null ? (int)$row['last_notification_seconds'] : null,
'failure_count' => $failureCount,
'last_failure_reason' => (string)($row['last_failure_reason'] ?? ''),
'health_status' => $status,
'health_text' => $statusText,
];
}
return $rows;
}
function push_health_summary(): array
{
$devices = push_device_rows();
$summary = [
'total' => count($devices),
'healthy' => 0,
'watch' => 0,
'stale' => 0,
'failed' => 0,
'pending' => 0,
];
foreach ($devices as $device) {
$status = (string)($device['health_status'] ?? 'pending');
if (!array_key_exists($status, $summary)) {
$status = 'pending';
}
$summary[$status]++;
}
return $summary;
}
function push_subscription_status(string $endpoint): array
{
$endpointHash = $endpoint !== '' ? hash('sha256', $endpoint) : '';
$matched = null;
if ($endpointHash !== '') {
$stmt = db()->prepare("
SELECT device_name
FROM push_subscriptions
WHERE endpoint_hash = :endpoint_hash
LIMIT 1
");
$stmt->execute([':endpoint_hash' => $endpointHash]);
$row = $stmt->fetch();
if ($row) {
$matched = $row;
}
}
$count = (int)db()->query("SELECT COUNT(*) FROM push_subscriptions")->fetchColumn();
return [
'subscriber_count' => $count,
'subscribed' => $matched !== null,
'device_name' => $matched['device_name'] ?? null,
];
}
function delete_push_device(string $endpointHash): void
{
if (!preg_match('/^[a-f0-9]{64}$/', $endpointHash)) {
json_out([
'ok' => false,
'error' => 'invalid_push_device',
], 422);
}
$stmt = db()->prepare("DELETE FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash");
$stmt->execute([':endpoint_hash' => $endpointHash]);
push_log_event('delete_device', [
'endpoint_hash' => $endpointHash,
'deleted' => $stmt->rowCount(),
]);
}
function delete_push_endpoint(string $endpoint): void
{
if ($endpoint === '') {
json_out([
'ok' => false,
'error' => 'invalid_push_endpoint',
], 422);
}
$stmt = db()->prepare("DELETE FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash");
$endpointHash = hash('sha256', $endpoint);
$stmt->execute([':endpoint_hash' => $endpointHash]);
push_log_event('unregister', [
'endpoint' => $endpoint,
'endpoint_hash' => $endpointHash,
'deleted' => $stmt->rowCount(),
]);
}
function send_push_payload(array $payload): array
{
$pushId = (string)($payload['push_id'] ?? '');
if ($pushId === '') {
$pushId = bin2hex(random_bytes(12));
$payload['push_id'] = $pushId;
}
$tag = (string)($payload['tag'] ?? '');
if (
!class_exists(\Minishlink\WebPush\WebPush::class)
|| !defined('VAPID_PUBLIC_KEY')
|| !defined('VAPID_PRIVATE_KEY')
) {
push_log_event('send_config_missing', [
'message' => 'web_push_not_configured',
'push_id' => $pushId,
'tag' => $tag,
]);
return [
'sent' => 0,
'failed' => 0,
'error' => 'web_push_not_configured',
];
}
$stmt = db()->query("
SELECT endpoint, p256dh, auth, content_encoding
FROM push_subscriptions
ORDER BY last_seen_at DESC
LIMIT 200
");
$rows = $stmt->fetchAll();
if ($rows === []) {
push_log_event('send_no_subscribers', [
'message' => $pushId,
'push_id' => $pushId,
'tag' => $tag,
]);
return [
'sent' => 0,
'failed' => 0,
'error' => null,
];
}
$webPush = new \Minishlink\WebPush\WebPush([
'VAPID' => [
'subject' => 'mailto:admin@' . APP_HOST,
'publicKey' => VAPID_PUBLIC_KEY,
'privateKey' => VAPID_PRIVATE_KEY,
],
]);
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$sent = 0;
$failed = 0;
push_log_event('send_request', [
'message' => $pushId,
'push_id' => $pushId,
'tag' => $tag,
'subscriber_count' => count($rows),
]);
foreach ($rows as $row) {
$subscription = \Minishlink\WebPush\Subscription::create([
'endpoint' => (string)$row['endpoint'],
'publicKey' => (string)$row['p256dh'],
'authToken' => (string)$row['auth'],
'contentEncoding' => (string)($row['content_encoding'] ?: 'aes128gcm'),
]);
$webPush->queueNotification($subscription, $json);
}
foreach ($webPush->flush() as $report) {
$endpoint = $report->getEndpoint();
if ($report->isSuccess()) {
$sent++;
push_log_event('send_success', [
'endpoint' => $endpoint,
'message' => $pushId,
'push_id' => $pushId,
'tag' => $tag,
]);
continue;
}
$failed++;
$reason = method_exists($report, 'getReason') ? (string)$report->getReason() : 'send_failed';
if ($report->isSubscriptionExpired()) {
$delete = db()->prepare("DELETE FROM push_subscriptions WHERE endpoint_hash = :endpoint_hash");
$delete->execute([':endpoint_hash' => hash('sha256', $endpoint)]);
push_log_event('send_expired', [
'endpoint' => $endpoint,
'message' => $pushId,
'push_id' => $pushId,
'tag' => $tag,
'reason' => $reason,
]);
continue;
}
push_log_event('send_failed', [
'endpoint' => $endpoint,
'message' => $pushId,
'push_id' => $pushId,
'tag' => $tag,
'reason' => $reason,
]);
}
return [
'sent' => $sent,
'failed' => $failed,
'error' => null,
];
}
function latest_push_send_epoch_by_tag(string $tagPrefix): int
{ {
$stmt = db()->prepare(" $stmt = db()->prepare("
SELECT UNIX_TIMESTAMP(MAX(created_at)) SELECT UNIX_TIMESTAMP(MAX(created_at))
FROM push_event_logs FROM ha_notify_logs
WHERE event = 'send_success' WHERE event = 'send_success'
AND meta LIKE :tag AND tag = :tag
"); ");
$stmt->execute([ $stmt->execute([':tag' => $tag]);
':tag' => '%\"tag\":\"' . str_replace(['%', '_'], ['\\%', '\\_'], $tagPrefix) . '%',
]);
return (int)($stmt->fetchColumn() ?: 0); return (int)($stmt->fetchColumn() ?: 0);
} }
function send_push_healthcheck_if_due(int $minHours = 24, bool $force = false): array function ha_notify_due(string $tag, int $cooldownSeconds): bool
{ {
$minHours = max(1, min(168, $minHours)); $latest = latest_ha_notify_epoch_by_tag($tag);
$latest = latest_push_send_epoch_by_tag('control-healthcheck-'); return $latest <= 0 || time() - $latest >= $cooldownSeconds;
}
if (!$force && $latest > 0 && time() - $latest < $minHours * 3600) { function ha_notify_default_data(string $level, string $tag, string $url = '/'): array
return [ {
'sent' => 0, $level = in_array($level, ['critical', 'warning', 'info'], true) ? $level : 'info';
'failed' => 0, $isCritical = $level === 'critical';
'error' => null, $isWarning = $level === 'warning';
'skipped' => true, $absoluteUrl = str_starts_with($url, 'http://') || str_starts_with($url, 'https://')
'message' => 'healthcheck_cooldown', ? $url
'next_after_seconds' => ($minHours * 3600) - (time() - $latest), : 'https://' . APP_HOST . '/' . ltrim($url, '/');
];
}
return send_push_payload([ return [
'title' => 'Seoul Control Center', 'ttl' => 0,
'body' => '푸시 기기 상태 확인 알림입니다.', 'priority' => 'high',
'url' => '/', 'channel' => $isCritical ? 'control_critical' : ($isWarning ? 'control_warning' : 'control_status'),
'tag' => 'control-healthcheck-' . date('YmdHi'), 'importance' => $isCritical ? 'max' : 'high',
'created_at' => date('Y-m-d H:i:s'), 'tag' => $tag,
'silent' => true, 'group' => 'control',
'data' => [ 'sticky' => $isCritical ? 'true' : 'false',
'kind' => 'push_healthcheck', 'persistent' => $isCritical ? 'true' : 'false',
'renotify' => 'true',
'color' => $isCritical ? '#ff3b30' : ($isWarning ? '#ffcc00' : '#3b82f6'),
'notification_icon' => $isCritical ? 'mdi:alert-circle' : ($isWarning ? 'mdi:alert' : 'mdi:information'),
'vibrationPattern' => $isCritical ? '900,250,900,250,1400' : '250,150,250',
'ledColor' => $isCritical ? 'red' : ($isWarning ? 'yellow' : 'blue'),
'visibility' => 'public',
'timeout' => $isCritical ? 0 : 1800,
'clickAction' => $absoluteUrl,
'url' => $absoluteUrl,
'actions' => [
[
'action' => 'URI',
'title' => 'Control',
'uri' => $absoluteUrl,
],
], ],
]); ];
} }
function reset_battery_low_push_state(): void function send_ha_notify(array $payload): array
{ {
$stmt = db()->prepare(" $title = mb_substr((string)($payload['title'] ?? APP_NAME), 0, 255);
INSERT INTO battery_push_state $message = mb_substr((string)($payload['message'] ?? $payload['body'] ?? ''), 0, 4000);
(id, last_sent_at, last_percent, last_voltage) $level = (string)($payload['level'] ?? 'info');
VALUES $tag = mb_substr((string)($payload['tag'] ?? 'control-notify'), 0, 128);
(1, NULL, NULL, NULL) $url = (string)($payload['url'] ?? '/');
ON DUPLICATE KEY UPDATE $data = ha_notify_default_data($level, $tag, $url);
last_sent_at = NULL,
last_percent = NULL,
last_voltage = NULL
");
$stmt->execute();
}
function battery_low_push_due(float $percent, ?float $voltage): bool if (is_array($payload['data'] ?? null)) {
{ $data = array_replace_recursive($data, $payload['data']);
$stmt = db()->query("
SELECT last_sent_at
FROM battery_push_state
WHERE id = 1
LIMIT 1
");
$row = $stmt->fetch() ?: [];
$lastSent = strtotime((string)($row['last_sent_at'] ?? '')) ?: 0;
if ($lastSent > 0 && time() - $lastSent < 10) {
return false;
} }
$save = db()->prepare(" $body = json_encode([
INSERT INTO battery_push_state 'title' => $title,
(id, last_sent_at, last_percent, last_voltage) 'message' => $message,
VALUES 'data' => $data,
(1, CURRENT_TIMESTAMP, :last_percent, :last_voltage) ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
ON DUPLICATE KEY UPDATE
last_sent_at = CURRENT_TIMESTAMP,
last_percent = VALUES(last_percent),
last_voltage = VALUES(last_voltage)
");
$save->execute([
':last_percent' => round($percent, 2),
':last_voltage' => $voltage === null ? null : round($voltage, 3),
]);
return true; $attempts = 0;
foreach (ha_notify_targets() as $target) {
$name = (string)($target['name'] ?? 'ha');
$base = rtrim((string)($target['base'] ?? $target['base_url'] ?? $target['url'] ?? ''), '/');
if ($base === '') {
continue;
}
$attempts++;
$ch = curl_init($base . '/api/webhook/' . rawurlencode(HA_NOTIFY_WEBHOOK_ID));
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => $body,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 8,
]);
$response = curl_exec($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = $response === false ? curl_error($ch) : null;
curl_close($ch);
if ($error === null && $httpCode >= 200 && $httpCode < 300) {
ha_notify_log('send_success', [
'target' => $name,
'title' => $title,
'tag' => $tag,
'http_code' => $httpCode,
'data' => $data,
]);
return [
'sent' => 1,
'failed' => 0,
'target' => $name,
'http_code' => $httpCode,
'error' => null,
];
}
ha_notify_log('send_failed', [
'target' => $name,
'title' => $title,
'tag' => $tag,
'http_code' => $httpCode,
'error' => $error ?: ('HTTP ' . $httpCode),
]);
}
return [
'sent' => 0,
'failed' => $attempts,
'target' => null,
'http_code' => null,
'error' => 'ha_notify_failed',
];
} }
function battery_soc_rising_from_history(float $percent): bool function battery_soc_rising_from_history(float $percent): bool
@@ -1118,7 +655,7 @@ function battery_soc_rising_from_history(float $percent): bool
&& ($percent - $olderAvg) >= 0.15; && ($percent - $olderAvg) >= 0.15;
} }
function send_battery_low_push_if_needed(array $battery): array function send_battery_low_notify_if_needed(array $battery): array
{ {
$percent = $battery['percent'] ?? null; $percent = $battery['percent'] ?? null;
if ($percent === null || $percent === '' || !is_numeric($percent)) { if ($percent === null || $percent === '' || !is_numeric($percent)) {
@@ -1135,7 +672,6 @@ function send_battery_low_push_if_needed(array $battery): array
: null; : null;
if ($percent > 20.0) { if ($percent > 20.0) {
reset_battery_low_push_state();
return [ return [
'sent' => 0, 'sent' => 0,
'failed' => 0, 'failed' => 0,
@@ -1143,15 +679,8 @@ function send_battery_low_push_if_needed(array $battery): array
]; ];
} }
if (battery_soc_rising_from_history($percent)) { $tag = 'control-battery-low';
return [ if (!ha_notify_due($tag, 10)) {
'sent' => 0,
'failed' => 0,
'skipped' => 'battery_soc_rising',
];
}
if (!battery_low_push_due($percent, $voltage)) {
return [ return [
'sent' => 0, 'sent' => 0,
'failed' => 0, 'failed' => 0,
@@ -1165,17 +694,22 @@ function send_battery_low_push_if_needed(array $battery): array
. ($voltage === null ? '' : ' / ' . number_format($voltage, 3) . 'V') . ($voltage === null ? '' : ' / ' . number_format($voltage, 3) . 'V')
. "\n20% 이하 상태입니다."; . "\n20% 이하 상태입니다.";
return send_push_payload([ return send_ha_notify([
'title' => '배터리 SOC 경고', 'title' => '배터리 SOC 경고',
'body' => $body, 'message' => $body,
'level' => 'critical',
'url' => '/', 'url' => '/',
'tag' => 'control-battery-low', 'tag' => $tag,
'renotify' => false,
'require_interaction' => true,
'silent' => false,
'vibrate' => [900, 250, 900, 250, 1400],
'created_at' => date('Y-m-d H:i:s'),
'data' => [ 'data' => [
'channel' => 'control_battery',
'notification_icon' => 'mdi:battery-alert',
'color' => '#ff3b30',
'sticky' => 'true',
'persistent' => 'true',
'renotify' => 'true',
'importance' => 'high',
'vibrationPattern' => '900,250,900,250,1400',
'ledColor' => 'red',
'battery_percent' => round($percent, 2), 'battery_percent' => round($percent, 2),
'battery_voltage' => $voltage === null ? null : round($voltage, 3), 'battery_voltage' => $voltage === null ? null : round($voltage, 3),
], ],
-13
View File
@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
$controlSecretConfig = $controlSecretConfig ?? (is_file('/home/seo/secret/control.php') ? require '/home/seo/secret/control.php' : []);
$controlVapidConfig = is_array($controlSecretConfig['vapid'] ?? null) ? $controlSecretConfig['vapid'] : [];
if (!defined('VAPID_PUBLIC_KEY')) {
define('VAPID_PUBLIC_KEY', (string)($controlVapidConfig['public_key'] ?? ''));
}
if (!defined('VAPID_PRIVATE_KEY')) {
define('VAPID_PRIVATE_KEY', (string)($controlVapidConfig['private_key'] ?? ''));
}
+22 -78
View File
@@ -1275,7 +1275,7 @@ function signed_delta_compact(float $value, int $decimals, string $suffix = ''):
return $sign . number_format($value, $decimals) . $suffix; return $sign . number_format($value, $decimals) . $suffix;
} }
function push_process_name(array $row): string function display_process_name(array $row): string
{ {
$service = (string)($row['service'] ?? ''); $service = (string)($row['service'] ?? '');
if ($service !== '' && $service !== 'N/A') { if ($service !== '' && $service !== 'N/A') {
@@ -1344,7 +1344,7 @@ function expected_process_text(array $processes): string
} }
if ($cpu !== null && (float)($cpu['cpu_percent'] ?? 0) >= 1.0) { if ($cpu !== null && (float)($cpu['cpu_percent'] ?? 0) >= 1.0) {
return 'CPU ' . push_process_name($cpu); return 'CPU ' . display_process_name($cpu);
} }
$mem = null; $mem = null;
@@ -1358,7 +1358,7 @@ function expected_process_text(array $processes): string
} }
if ($mem !== null) { if ($mem !== null) {
return 'MEM ' . push_process_name($mem); return 'MEM ' . display_process_name($mem);
} }
return 'CPU/MEM 원인 후보 없음'; return 'CPU/MEM 원인 후보 없음';
@@ -1398,17 +1398,17 @@ function expected_process_detail_text(array $processes): string
} }
if ($cpu !== null && $mem !== null && process_identity($mem) === process_identity($cpu)) { if ($cpu !== null && $mem !== null && process_identity($mem) === process_identity($cpu)) {
return push_process_name($cpu); return display_process_name($cpu);
} }
$parts = []; $parts = [];
if ($cpu !== null) { if ($cpu !== null) {
$parts[] = sprintf('CPU %.1f%% %s', (float)($cpu['cpu_percent'] ?? 0), push_process_name($cpu)); $parts[] = sprintf('CPU %.1f%% %s', (float)($cpu['cpu_percent'] ?? 0), display_process_name($cpu));
} }
if ($mem !== null) { if ($mem !== null) {
$parts[] = sprintf('RAM %.1f%% %s', (float)($mem['mem_percent'] ?? 0), push_process_name($mem)); $parts[] = sprintf('RAM %.1f%% %s', (float)($mem['mem_percent'] ?? 0), display_process_name($mem));
} }
return $parts === [] ? '원인 후보 없음' : implode(' / ', $parts); return $parts === [] ? '원인 후보 없음' : implode(' / ', $parts);
@@ -1439,7 +1439,7 @@ function notice_downward_only(float $tempDelta, float $rpmDelta): bool
return true; return true;
} }
function send_fan_spike_push(array $spike, array $fan, array $system, array $processes): array function send_fan_spike_notify(array $spike, array $fan, array $system, array $processes): array
{ {
$tempDelta = (float)($spike['temp_delta'] ?? 0); $tempDelta = (float)($spike['temp_delta'] ?? 0);
$rpmDelta = (float)($spike['rpm_delta'] ?? 0); $rpmDelta = (float)($spike['rpm_delta'] ?? 0);
@@ -1476,13 +1476,18 @@ function send_fan_spike_push(array $spike, array $fan, array $system, array $pro
. number_format((float)($fan['pwm'] ?? 0), 0) . number_format((float)($fan['pwm'] ?? 0), 0)
. (notice_downward_only($tempDelta, $rpmDelta) ? '' : "\n원인 후보: " . expected_process_detail_text($processes)); . (notice_downward_only($tempDelta, $rpmDelta) ? '' : "\n원인 후보: " . expected_process_detail_text($processes));
return send_push_payload([ return send_ha_notify([
'title' => '시스템 유의사항', 'title' => '시스템 유의사항',
'body' => $body, 'message' => $body,
'level' => 'warning',
'url' => '/', 'url' => '/',
'tag' => 'system-notice-' . date('YmdHi'), 'tag' => 'control-system-notice',
'created_at' => date('Y-m-d H:i:s'),
'data' => [ 'data' => [
'channel' => 'control_system',
'notification_icon' => 'mdi:fan-alert',
'color' => '#f59e0b',
'sticky' => 'false',
'renotify' => 'true',
'summary' => $spike['summary'] ?? '', 'summary' => $spike['summary'] ?? '',
'rpm_delta' => $spike['rpm_delta'] ?? 0, 'rpm_delta' => $spike['rpm_delta'] ?? 0,
'pwm_delta' => $spike['pwm_delta'] ?? 0, 'pwm_delta' => $spike['pwm_delta'] ?? 0,
@@ -1492,16 +1497,9 @@ function send_fan_spike_push(array $spike, array $fan, array $system, array $pro
]); ]);
} }
function latest_system_notice_push_epoch(): int function latest_system_notice_notify_epoch(): int
{ {
$stmt = db()->query(" return latest_ha_notify_epoch_by_tag('control-system-notice');
SELECT UNIX_TIMESTAMP(MAX(created_at))
FROM push_event_logs
WHERE event = 'send_success'
AND meta LIKE '%\"tag\":\"system-notice-%'
");
return (int)($stmt->fetchColumn() ?: 0);
} }
function fan_spike_history(int $limit = 100): array function fan_spike_history(int $limit = 100): array
@@ -1981,14 +1979,14 @@ function collect_snapshot(bool $applyFan = true): array
$fanSpike = fan_spike_analysis($history, $fan, $system, $processes); $fanSpike = fan_spike_analysis($history, $fan, $system, $processes);
if (!empty($fanSpike['active'])) { if (!empty($fanSpike['active'])) {
$latestNoticePush = latest_system_notice_push_epoch(); $latestNoticeNotify = latest_system_notice_notify_epoch();
$spikeLogId = add_fan_spike_log($fanSpike, $fan, $system, $processes); $spikeLogId = add_fan_spike_log($fanSpike, $fan, $system, $processes);
if ($spikeLogId > 0) { if ($spikeLogId > 0) {
$fanSpike['log_id'] = $spikeLogId; $fanSpike['log_id'] = $spikeLogId;
if ($latestNoticePush <= 0 || time() - $latestNoticePush >= 600) { if ($latestNoticeNotify <= 0 || time() - $latestNoticeNotify >= 600) {
$fanSpike['push'] = send_fan_spike_push($fanSpike, $fan, $system, $processes); $fanSpike['notify'] = send_fan_spike_notify($fanSpike, $fan, $system, $processes);
} else { } else {
$fanSpike['push'] = [ $fanSpike['notify'] = [
'sent' => 0, 'sent' => 0,
'failed' => 0, 'failed' => 0,
'skipped' => 'system_notice_cooldown', 'skipped' => 'system_notice_cooldown',
@@ -2040,60 +2038,6 @@ function control_api_dispatch(): void
]); ]);
} }
if ($action === 'push_devices') {
json_out([
'ok' => true,
'data' => [
'devices' => push_device_rows(),
'summary' => push_health_summary(),
],
]);
}
if ($action === 'send_push_healthcheck') {
require_csrf();
json_out([
'ok' => true,
'data' => [
'result' => send_push_healthcheck_if_due(24, true),
'devices' => push_device_rows(),
'summary' => push_health_summary(),
],
]);
}
if ($action === 'push_status') {
json_out([
'ok' => true,
'data' => push_subscription_status((string)($_GET['endpoint'] ?? $_POST['endpoint'] ?? '')),
]);
}
if ($action === 'delete_push_device') {
delete_push_device((string)($_POST['endpoint_hash'] ?? ''));
json_out([
'ok' => true,
'data' => [
'devices' => push_device_rows(),
'summary' => push_health_summary(),
],
]);
}
if ($action === 'delete_push_endpoint') {
delete_push_endpoint((string)($_POST['endpoint'] ?? ''));
json_out([
'ok' => true,
'data' => [
'devices' => push_device_rows(),
'summary' => push_health_summary(),
],
]);
}
if ($action === 'fan') { if ($action === 'fan') {
$mode = (string)($_POST['mode'] ?? 'auto'); $mode = (string)($_POST['mode'] ?? 'auto');
$pwm = max(0, min(255, (int)($_POST['pwm'] ?? 120))); $pwm = max(0, min(255, (int)($_POST['pwm'] ?? 120)));
-44
View File
@@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../config/config.php';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
}
$raw = (string)file_get_contents('php://input');
$data = json_decode($raw, true);
if (!is_array($data)) {
$data = [];
}
$event = (string)($data['event'] ?? '');
$allowedEvents = [
'push_received',
'notification_shown',
'notification_show_failed',
'notification_click',
'notification_close',
'client_log_failed',
];
if (!in_array($event, $allowedEvents, true)) {
json_out(['ok' => false, 'error' => 'invalid_event'], 422);
}
$endpoint = (string)($data['endpoint'] ?? '');
$meta = is_array($data['meta'] ?? null) ? $data['meta'] : [];
$context = [
'endpoint' => $endpoint,
'message' => mb_substr((string)($data['push_id'] ?? ''), 0, 255),
'push_id' => (string)($data['push_id'] ?? ''),
'tag' => (string)($data['tag'] ?? ''),
'visibility_state' => (string)($data['visibility_state'] ?? ''),
'client_count' => isset($data['client_count']) ? (int)$data['client_count'] : null,
'meta' => $meta,
];
push_log_event($event, $context);
json_out(['ok' => true]);
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once __DIR__ . '/../../config/config.php';
if (!signed_in()) {
json_out([
'ok' => false,
'error' => 'login_required',
], 401);
}
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
json_out([
'ok' => false,
'error' => 'method_not_allowed',
], 405);
}
require_csrf();
$subscription = push_subscription_from_json((string)file_get_contents('php://input'));
save_push_subscription($subscription);
json_out([
'ok' => true,
]);
-38
View File
@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once __DIR__ . '/../../config/config.php';
if (!signed_in()) {
json_out([
'ok' => false,
'error' => 'login_required',
], 401);
}
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
json_out([
'ok' => false,
'error' => 'method_not_allowed',
], 405);
}
require_csrf();
$data = push_subscription_from_json((string)file_get_contents('php://input'));
$payload = [
'title' => (string)($data['title'] ?? 'Seoul Control Center'),
'body' => (string)($data['body'] ?? 'Push notification test'),
'url' => '/',
'tag' => (string)($data['tag'] ?? 'control-test'),
'created_at' => date('Y-m-d H:i:s'),
];
json_out([
'ok' => true,
'data' => send_push_payload($payload),
]);
-202
View File
@@ -34,9 +34,6 @@
statusBatteryRemaining: $('#statusBatteryRemaining'), statusBatteryRemaining: $('#statusBatteryRemaining'),
spikeLogList: $('#spikeLogList'), spikeLogList: $('#spikeLogList'),
noticeBaseline: $('#noticeBaseline'), noticeBaseline: $('#noticeBaseline'),
pushStatus: $('#pushStatus'),
pushDeviceList: $('#pushDeviceList'),
pushHealthcheckBtn: $('#pushHealthcheckBtn'),
rebootBtn: $('#rebootBtn'), rebootBtn: $('#rebootBtn'),
translateBtn: $('#translateBtn'), translateBtn: $('#translateBtn'),
themeBtn: $('#themeBtn'), themeBtn: $('#themeBtn'),
@@ -70,7 +67,6 @@
wsFallbackTimer: null, wsFallbackTimer: null,
wsReconnectTimer: null, wsReconnectTimer: null,
wsReconnectDelay: 1000, wsReconnectDelay: 1000,
pushDevicesLastRefresh: 0,
rebootRequesting: false, rebootRequesting: false,
lang: 'en', lang: 'en',
theme: 'dark', theme: 'dark',
@@ -141,10 +137,6 @@
systemNotice: 'System Notice', systemNotice: 'System Notice',
noticeHistory: 'Notice History', noticeHistory: 'Notice History',
noNoticeHistory: 'No system notice history.', noNoticeHistory: 'No system notice history.',
pushDevices: 'Push Devices',
healthCheck: 'Health Check',
pushStatusChecking: 'Push status checking...',
noPushDevices: 'No push devices.',
show: 'Show', show: 'Show',
hide: 'Hide', hide: 'Hide',
translateButton: 'Translate', translateButton: 'Translate',
@@ -179,34 +171,8 @@
avg: 'AVG', avg: 'AVG',
min: 'MIN', min: 'MIN',
minuteAgo: ({ minutes }) => `${minutes}m ago`, minuteAgo: ({ minutes }) => `${minutes}m ago`,
health_healthy: 'Healthy',
health_watch: 'Watch',
health_stale: 'Stale',
health_failed: 'Failed',
health_pending: 'Pending',
pushSummary: ({ total, healthy, watch, stale, failed, pending }) => `Push devices: ${total} total / ${healthy} healthy / ${watch} watch / ${stale} stale / ${failed} failed / ${pending} pending`,
statusUnsupported: 'Push status: browser unsupported',
statusDenied: 'Push status: permission denied',
statusUngrant: 'Push status: permission not granted',
statusManualOff: 'Push status: manually disabled',
browserHas: 'browser registered',
browserMissing: 'browser missing',
serverHas: 'server registered',
serverMissing: 'server missing',
serverPending: 'server not checked',
pushStatus: 'Push status',
registeredRefresh: 'Registered refresh',
lastSendSuccess: 'Last send success',
lastReceived: 'Last received',
lastShown: 'Last shown',
lastClick: 'Last click',
failures: 'Failures',
unknownDevice: 'unknown device',
unnamed: 'Unnamed', unnamed: 'Unnamed',
pendingReceive: 'Pending receive',
ago: 'ago', ago: 'ago',
healthSent: ({ sent, failed }) => `Health check sent ${sent} / failed ${failed}`,
healthFailed: 'Health check failed',
refreshFailed: 'refresh failed', refreshFailed: 'refresh failed',
applyingFan: 'Applying fan policy...', applyingFan: 'Applying fan policy...',
fanUpdated: 'Fan updated', fanUpdated: 'Fan updated',
@@ -225,16 +191,7 @@
rebootStarted: 'Reboot Started', rebootStarted: 'Reboot Started',
rebootStartedBody: 'Reboot command sent. The connection may drop shortly.', rebootStartedBody: 'Reboot command sent. The connection may drop shortly.',
rebootFailed: 'Reboot Failed', rebootFailed: 'Reboot Failed',
pushDeviceNamePrompt: 'Enter a device name. Korean names require at least 2 Hangul characters.',
pushRegisterTitle: 'Push Register',
register: 'Register',
phone: 'Phone',
pushDeviceNameInvalid: 'Device name must contain at least 2 Hangul characters.',
autoRepair: 'Auto Repair',
pushDeletePrompt: 'Delete this browser Push subscription?',
pushDeleteTitle: 'Delete Push',
delete: 'Delete', delete: 'Delete',
pushErrorTitle: 'Push Error',
wakeLockTitle: 'Prevent screen sleep', wakeLockTitle: 'Prevent screen sleep',
wakeLockUnsupported: 'WakeLock is not supported by this browser.', wakeLockUnsupported: 'WakeLock is not supported by this browser.',
wakeLockErrorTitle: 'WakeLock Error', wakeLockErrorTitle: 'WakeLock Error',
@@ -298,10 +255,6 @@
systemNotice: '시스템 알림', systemNotice: '시스템 알림',
noticeHistory: '알림 이력', noticeHistory: '알림 이력',
noNoticeHistory: '시스템 알림 이력이 없습니다.', noNoticeHistory: '시스템 알림 이력이 없습니다.',
pushDevices: 'Push 기기',
healthCheck: '상태 점검',
pushStatusChecking: 'Push 상태 확인 중...',
noPushDevices: 'Push 기기가 없습니다.',
show: '보기', show: '보기',
hide: '숨기기', hide: '숨기기',
translateButton: '번역', translateButton: '번역',
@@ -336,34 +289,8 @@
avg: '평균', avg: '평균',
min: '최소', min: '최소',
minuteAgo: ({ minutes }) => `${minutes}분 전`, minuteAgo: ({ minutes }) => `${minutes}분 전`,
health_healthy: '정상',
health_watch: '주의',
health_stale: '장기 미수신',
health_failed: '실패',
health_pending: '수신 대기',
pushSummary: ({ total, healthy, watch, stale, failed, pending }) => `Push 기기: 총 ${total} / 정상 ${healthy} / 주의 ${watch} / 장기 미수신 ${stale} / 실패 ${failed} / 대기 ${pending}`,
statusUnsupported: 'Push 상태: 브라우저 미지원',
statusDenied: 'Push 상태: 권한 꺼짐',
statusUngrant: 'Push 상태: 권한 미허용',
statusManualOff: 'Push 상태: 사용자가 직접 해제함',
browserHas: '브라우저에는 있음',
browserMissing: '브라우저에는 없음',
serverHas: '서버에는 있음',
serverMissing: '서버에는 없음',
serverPending: '서버 확인 전',
pushStatus: 'Push 상태',
registeredRefresh: '등록 갱신',
lastSendSuccess: '마지막 발송 성공',
lastReceived: '마지막 수신',
lastShown: '마지막 표시',
lastClick: '마지막 클릭',
failures: '실패',
unknownDevice: '알 수 없는 기기',
unnamed: '이름 없음', unnamed: '이름 없음',
pendingReceive: '수신 대기',
ago: '전', ago: '전',
healthSent: ({ sent, failed }) => `상태 점검 발송 ${sent} / 실패 ${failed}`,
healthFailed: '상태 점검 실패',
refreshFailed: '갱신 실패', refreshFailed: '갱신 실패',
applyingFan: '팬 정책 적용 중...', applyingFan: '팬 정책 적용 중...',
fanUpdated: '팬 갱신 완료', fanUpdated: '팬 갱신 완료',
@@ -382,16 +309,7 @@
rebootStarted: '재부팅 시작', rebootStarted: '재부팅 시작',
rebootStartedBody: '재부팅 명령을 전송했습니다. 잠시 후 연결이 끊어질 수 있습니다.', rebootStartedBody: '재부팅 명령을 전송했습니다. 잠시 후 연결이 끊어질 수 있습니다.',
rebootFailed: '재부팅 실패', rebootFailed: '재부팅 실패',
pushDeviceNamePrompt: '등록할 기기 이름을 입력하세요. 한글 2글자 이상이어야 합니다.',
pushRegisterTitle: 'Push 등록',
register: '등록',
phone: '휴대폰',
pushDeviceNameInvalid: '기기 이름은 한글 2글자 이상이어야 합니다.',
autoRepair: '자동복구',
pushDeletePrompt: '현재 브라우저의 Push 구독을 삭제할까요?',
pushDeleteTitle: 'Push 삭제',
delete: '삭제', delete: '삭제',
pushErrorTitle: 'Push 오류',
wakeLockTitle: '화면 꺼짐 방지', wakeLockTitle: '화면 꺼짐 방지',
wakeLockUnsupported: '현재 브라우저에서 WakeLock을 지원하지 않습니다.', wakeLockUnsupported: '현재 브라우저에서 WakeLock을 지원하지 않습니다.',
wakeLockErrorTitle: 'WakeLock 오류', wakeLockErrorTitle: 'WakeLock 오류',
@@ -408,11 +326,6 @@
} }
window.controlT = t; window.controlT = t;
function pushHealthText(status, fallback = '') {
const key = `health_${status || 'pending'}`;
return messages[state.lang]?.[key] ?? messages.en[key] ?? fallback ?? t('pendingReceive');
}
function applyTheme(theme) { function applyTheme(theme) {
state.theme = theme === 'light' ? 'light' : 'dark'; state.theme = theme === 'light' ? 'light' : 'dark';
localStorage.setItem(storageKeys.theme, state.theme); localStorage.setItem(storageKeys.theme, state.theme);
@@ -1084,119 +997,6 @@
: `<div class="spike-log-empty">${escapeHtml(t('noNoticeHistory'))}</div>`; : `<div class="spike-log-empty">${escapeHtml(t('noNoticeHistory'))}</div>`;
} }
function renderPushDevices(devices = [], summary = null) {
if (!els.pushDeviceList) return;
if (summary && els.pushStatus) {
const total = Number(summary.total || 0);
const healthy = Number(summary.healthy || 0);
const watch = Number(summary.watch || 0);
const stale = Number(summary.stale || 0);
const failed = Number(summary.failed || 0);
const pending = Number(summary.pending || 0);
els.pushStatus.textContent = t('pushSummary', { total, healthy, watch, stale, failed, pending });
}
els.pushDeviceList.innerHTML = devices.length
? devices.map(device => {
const healthStatus = device.health_status || 'pending';
const healthText = pushHealthText(healthStatus, device.health_text);
return `
<div class="push-device-row ${escapeHtml(healthStatus)}">
<div class="push-device-main">
<strong>${escapeHtml(device.device_name || t('unnamed'))}</strong>
<span class="push-device-badge ${escapeHtml(healthStatus)}">${escapeHtml(healthText)}</span>
<span>${escapeHtml(t('hostLabel'))}: ${escapeHtml(device.host || 'unknown')}</span>
<span>${escapeHtml(t('ipLabel'))}: ${escapeHtml(device.actor_ip || '-')}</span>
<span>${escapeHtml(t('created'))}: ${escapeHtml(device.created_at || '-')}</span>
<span>${escapeHtml(t('registeredRefresh'))}: ${escapeHtml(device.last_seen_at || '-')} (${escapeHtml(timeAgo(device.last_seen_seconds))})</span>
<span>${escapeHtml(t('lastSendSuccess'))}: ${escapeHtml(device.last_send_success_at || '-')} (${escapeHtml(timeAgo(device.last_send_success_seconds))})</span>
<span>${escapeHtml(t('lastReceived'))}: ${escapeHtml(device.last_received_at || '-')} (${escapeHtml(timeAgo(device.last_received_seconds))})</span>
<span>${escapeHtml(t('lastShown'))}: ${escapeHtml(device.last_notification_at || '-')} (${escapeHtml(timeAgo(device.last_notification_seconds))})</span>
<span>${escapeHtml(t('lastClick'))}: ${escapeHtml(device.last_click_at || '-')}</span>
<span>${escapeHtml(t('failures'))}: ${Number(device.failure_count || 0).toLocaleString()}${device.last_failure_reason ? ` / ${escapeHtml(device.last_failure_reason)}` : ''}</span>
<span>${escapeHtml(t('encoding'))}: ${escapeHtml(device.content_encoding || '-')}</span>
<span>${escapeHtml(t('hash'))}: ${escapeHtml(device.hash || '-')}</span>
<span>${escapeHtml(t('endpoint'))}: ${escapeHtml(device.endpoint || '-')}</span>
<span>${escapeHtml(t('userAgent'))}: ${escapeHtml(device.user_agent || t('unknownDevice'))}</span>
</div>
</div>
`;
}).join('')
: `<div class="spike-log-empty">${escapeHtml(t('noPushDevices'))}</div>`;
}
function renderPushStatus(detail = {}) {
if (!els.pushStatus) return;
const supported = detail.supported === true;
const permission = detail.permission || 'unknown';
const hasBrowserSubscription = detail.browser_subscription === true;
const hasServerSubscription = detail.server_subscription === true;
const manualDisabled = detail.manual_disabled === true;
const serverChecked = detail.server_checked === true;
let text = '';
if (!supported) {
text = t('statusUnsupported');
} else if (permission === 'denied') {
text = t('statusDenied');
} else if (permission !== 'granted') {
text = t('statusUngrant');
} else if (manualDisabled) {
text = t('statusManualOff');
} else {
const browserText = hasBrowserSubscription ? t('browserHas') : t('browserMissing');
const serverText = serverChecked
? (hasServerSubscription ? t('serverHas') : t('serverMissing'))
: t('serverPending');
text = `${t('pushStatus')}: ${browserText} / ${serverText}`;
}
els.pushStatus.textContent = text;
}
async function refreshPushDevices(force = false) {
if (!force && Date.now() - state.pushDevicesLastRefresh < 30000) {
return;
}
state.pushDevicesLastRefresh = Date.now();
try {
const data = await api('push_devices');
renderPushDevices(data.devices || [], data.summary || null);
} catch (e) {
console.error(e);
}
}
async function sendPushHealthcheck() {
if (!els.pushHealthcheckBtn) return;
els.pushHealthcheckBtn.disabled = true;
try {
const data = await api('send_push_healthcheck', {});
renderPushDevices(data.devices || [], data.summary || null);
const result = data.result || {};
notice(t('healthSent', { sent: Number(result.sent || 0), failed: Number(result.failed || 0) }), result.failed ? 'error' : 'success');
} catch (e) {
notice(e.message || t('healthFailed'), 'error');
} finally {
els.pushHealthcheckBtn.disabled = false;
}
}
window.addEventListener('pushdevices:refresh', () => {
refreshPushDevices(true);
});
window.addEventListener('pushstatus:update', event => {
renderPushStatus(event.detail || {});
});
els.pushHealthcheckBtn?.addEventListener('click', sendPushHealthcheck);
function renderFanCause(data) { function renderFanCause(data) {
const processes = data.processes || {}; const processes = data.processes || {};
const baseline = data.fan_spike || {}; const baseline = data.fan_spike || {};
@@ -1211,7 +1011,6 @@
renderProcessRows(els.processCpuTable, processes.cpu || [], 'cpu'); renderProcessRows(els.processCpuTable, processes.cpu || [], 'cpu');
renderProcessRows(els.processMemoryTable, processes.memory || [], 'memory'); renderProcessRows(els.processMemoryTable, processes.memory || [], 'memory');
refreshPushDevices();
} }
function rollingStats(values, windowSize = 30) { function rollingStats(values, windowSize = 30) {
@@ -1736,7 +1535,6 @@
}); });
els.translateBtn?.addEventListener('click', () => { els.translateBtn?.addEventListener('click', () => {
applyLang(state.lang === 'ko' ? 'en' : 'ko'); applyLang(state.lang === 'ko' ? 'en' : 'ko');
refreshPushDevices(true);
refreshStatus(); refreshStatus();
}); });
els.themeBtn?.addEventListener('click', () => { els.themeBtn?.addEventListener('click', () => {
+1 -33
View File
@@ -37,22 +37,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login_password'])) {
$loggedIn = signed_in(); $loggedIn = signed_in();
$csrf = csrf_token(); $csrf = csrf_token();
if ($loggedIn && array_key_exists('push', $_GET)) {
$testPushBody = trim((string)$_GET['push'], " \t\n\r\0\x0B\"'");
if ($testPushBody !== '') {
send_push_payload([
'title' => '푸시 알림 테스트',
'body' => mb_substr($testPushBody, 0, 500),
'url' => '/',
'tag' => 'control-test-push-' . time(),
'created_at' => date('Y-m-d H:i:s'),
]);
}
header('Location: /');
exit;
}
?> ?>
<!doctype html> <!doctype html>
<html lang="ko"> <html lang="ko">
@@ -61,7 +45,6 @@ if ($loggedIn && array_key_exists('push', $_GET)) {
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="csrf-token" content="<?= e($csrf) ?>"> <meta name="csrf-token" content="<?= e($csrf) ?>">
<meta name="vapid-public-key" content="<?= e(vapid_public_key()) ?>">
<title><?= e(APP_NAME) ?></title> <title><?= e(APP_NAME) ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -123,7 +106,7 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
.table-wrap{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;border-radius:16px;border:1px solid var(--line)}.wifi-table{width:100%;min-width:1080px;table-layout:fixed;border-collapse:collapse}.wifi-table th,.wifi-table td{padding:11px 13px;border-bottom:1px solid rgba(128,145,170,.18);text-align:left;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wifi-table th{position:sticky;top:0;background:var(--table-head);z-index:1;font-weight:500}.wifi-table tr:hover{background:rgba(128,145,170,.09)}.wifi-table .col-band{width:72px}.wifi-table .col-host{width:240px}.wifi-table .col-ip{width:132px}.wifi-table .col-mac{width:170px}.wifi-table .col-signal{width:100px}.wifi-table .col-rate{width:120px}.wifi-table .col-time{width:130px} .table-wrap{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;border-radius:16px;border:1px solid var(--line)}.wifi-table{width:100%;min-width:1080px;table-layout:fixed;border-collapse:collapse}.wifi-table th,.wifi-table td{padding:11px 13px;border-bottom:1px solid rgba(128,145,170,.18);text-align:left;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wifi-table th{position:sticky;top:0;background:var(--table-head);z-index:1;font-weight:500}.wifi-table tr:hover{background:rgba(128,145,170,.09)}.wifi-table .col-band{width:72px}.wifi-table .col-host{width:240px}.wifi-table .col-ip{width:132px}.wifi-table .col-mac{width:170px}.wifi-table .col-signal{width:100px}.wifi-table .col-rate{width:120px}.wifi-table .col-time{width:130px}
.chart-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.chart-box{height:280px;background:var(--input);border:1px solid rgba(84,101,128,.58);border-radius:16px;padding:12px 12px 18px}.chart-box h3{margin:0 0 8px;color:var(--sub);font-size:13px}.chart-canvas{position:relative;height:calc(100% - 27px);min-height:0}.chart-canvas canvas{width:100%!important;height:100%!important} .chart-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.chart-box{height:280px;background:var(--input);border:1px solid rgba(84,101,128,.58);border-radius:16px;padding:12px 12px 18px}.chart-box h3{margin:0 0 8px;color:var(--sub);font-size:13px}.chart-canvas{position:relative;height:calc(100% - 27px);min-height:0}.chart-canvas canvas{width:100%!important;height:100%!important}
.resource-card{margin-top:18px}.resource-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:16px}.resource-head h2{margin:0}.baseline-pill{color:var(--sub);font-size:12px;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}.spike-log-box{margin-top:16px}.spike-log-box h3{margin:0 0 9px;color:var(--text);font-size:14px;font-weight:500}.spike-log-list{display:grid;gap:8px;max-height:260px;overflow:auto;padding-right:4px}.spike-log-item{display:grid;gap:4px;padding:10px 12px;border:1px solid var(--notice-border);background:var(--notice-bg);border-radius:12px}.spike-log-item.latest{border:1px solid var(--notice-border-strong);background:var(--notice-bg-strong);box-shadow:0 0 0 1px var(--notice-border) inset;}.spike-log-item strong{font-size:13px;font-weight:500;color:var(--notice-title)}.spike-log-item span{font-size:12px;color:var(--notice-copy);font-variant-numeric:tabular-nums}.spike-log-empty{padding:10px 12px;border-radius:12px;background:var(--card2);color:var(--sub);font-size:13px}.dmesg-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.dmesg-meta{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}.dmesg-log{margin:10px 0 0;max-height:420px;overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--code-bg);color:var(--mono-text);padding:12px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word}.dmesg-log[hidden]{display:none} .resource-card{margin-top:18px}.resource-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:16px}.resource-head h2{margin:0}.baseline-pill{color:var(--sub);font-size:12px;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}.spike-log-box{margin-top:16px}.spike-log-box h3{margin:0 0 9px;color:var(--text);font-size:14px;font-weight:500}.spike-log-list{display:grid;gap:8px;max-height:260px;overflow:auto;padding-right:4px}.spike-log-item{display:grid;gap:4px;padding:10px 12px;border:1px solid var(--notice-border);background:var(--notice-bg);border-radius:12px}.spike-log-item.latest{border:1px solid var(--notice-border-strong);background:var(--notice-bg-strong);box-shadow:0 0 0 1px var(--notice-border) inset;}.spike-log-item strong{font-size:13px;font-weight:500;color:var(--notice-title)}.spike-log-item span{font-size:12px;color:var(--notice-copy);font-variant-numeric:tabular-nums}.spike-log-empty{padding:10px 12px;border-radius:12px;background:var(--card2);color:var(--sub);font-size:13px}.dmesg-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.dmesg-meta{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}.dmesg-log{margin:10px 0 0;max-height:420px;overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--code-bg);color:var(--mono-text);padding:12px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word}.dmesg-log[hidden]{display:none}
.resource-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.resource-box{min-width:0}.resource-box h3{margin:0 0 9px;color:var(--sub);font-size:14px;font-weight:500}.resource-table{width:100%;min-width:620px;table-layout:fixed;border-collapse:collapse;border:1px solid var(--line);border-radius:14px;overflow:hidden}.resource-table th,.resource-table td{padding:9px 10px;border-bottom:1px solid rgba(128,145,170,.18);background:var(--input);text-align:left;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.resource-table th{background:var(--table-head);color:var(--sub);font-weight:500}.resource-table .pid{width:72px}.resource-table .metric{width:82px}.resource-table .service{width:150px}.resource-table .cmd{width:auto}.push-device-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}.push-device-head h3{margin:0}.push-device-actions{display:flex;gap:8px;flex-wrap:wrap}.push-device-list{display:grid;gap:8px;margin-top:8px}.push-device-row{padding:10px 12px;border:1px solid rgba(84,101,128,.58);background:var(--input);border-radius:12px}.push-device-row.healthy{border-color:rgba(53,196,107,.45)}.push-device-row.watch{border-color:rgba(255,204,0,.48)}.push-device-row.stale,.push-device-row.failed{border-color:rgba(255,95,87,.55)}.push-device-main{min-width:0;color:var(--sub);font-size:12px;line-height:1.45;display:grid;gap:2px;word-break:break-all}.push-device-main strong{color:var(--text);font-size:13px}.push-device-badge{display:inline-flex;align-items:center;width:max-content;border-radius:999px;padding:2px 8px;background:#2b3342;color:#edf1f7;font-size:11px}.push-device-badge.healthy{background:#198754}.push-device-badge.watch{background:#8a6d1b}.push-device-badge.stale,.push-device-badge.failed{background:#c62828} .resource-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.resource-box{min-width:0}.resource-box h3{margin:0 0 9px;color:var(--sub);font-size:14px;font-weight:500}.resource-table{width:100%;min-width:620px;table-layout:fixed;border-collapse:collapse;border:1px solid var(--line);border-radius:14px;overflow:hidden}.resource-table th,.resource-table td{padding:9px 10px;border-bottom:1px solid rgba(128,145,170,.18);background:var(--input);text-align:left;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.resource-table th{background:var(--table-head);color:var(--sub);font-weight:500}.resource-table .pid{width:72px}.resource-table .metric{width:82px}.resource-table .service{width:150px}.resource-table .cmd{width:auto}
.notice{position:fixed;right:20px;bottom:20px;min-width:220px;max-width:420px;padding:14px 16px;border-radius:14px;font-weight:500;background:var(--table-head);color:var(--text);border:1px solid var(--line);box-shadow:var(--shadow);z-index:99}.notice:empty{display:none}.notice[data-type=success]{border-color:rgba(53,196,107,.5)}.notice[data-type=error]{border-color:rgba(255,95,87,.5)} .notice{position:fixed;right:20px;bottom:20px;min-width:220px;max-width:420px;padding:14px 16px;border-radius:14px;font-weight:500;background:var(--table-head);color:var(--text);border:1px solid var(--line);box-shadow:var(--shadow);z-index:99}.notice:empty{display:none}.notice[data-type=success]{border-color:rgba(53,196,107,.5)}.notice[data-type=error]{border-color:rgba(255,95,87,.5)}
.dialog-layer{position:fixed;inset:0;display:none;align-items:center;justify-content:center;padding:22px;background:rgba(4,7,12,.68);backdrop-filter:blur(8px);z-index:120}.dialog-layer[data-open="1"]{display:flex}.dialog-box{width:min(430px,100%);border:1px solid rgba(84,101,128,.78);border-radius:20px;background:var(--card);box-shadow:0 24px 70px rgba(0,0,0,.42);padding:20px}.dialog-box h3{margin:0 0 8px;font-size:19px;font-weight:500}.dialog-message{margin:0 0 16px;color:var(--sub);line-height:1.5;white-space:pre-wrap}.dialog-input{width:100%;height:48px;margin-bottom:14px;border-radius:14px;border:1px solid var(--line);background:var(--input);color:var(--text);padding:0 14px;outline:none}.dialog-input:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}.dialog-actions{display:flex;justify-content:flex-end;gap:10px}.dialog-actions .btn{min-width:84px}.dialog-actions .btn.red{background:#c62828} .dialog-layer{position:fixed;inset:0;display:none;align-items:center;justify-content:center;padding:22px;background:rgba(4,7,12,.68);backdrop-filter:blur(8px);z-index:120}.dialog-layer[data-open="1"]{display:flex}.dialog-box{width:min(430px,100%);border:1px solid rgba(84,101,128,.78);border-radius:20px;background:var(--card);box-shadow:0 24px 70px rgba(0,0,0,.42);padding:20px}.dialog-box h3{margin:0 0 8px;font-size:19px;font-weight:500}.dialog-message{margin:0 0 16px;color:var(--sub);line-height:1.5;white-space:pre-wrap}.dialog-input{width:100%;height:48px;margin-bottom:14px;border-radius:14px;border:1px solid var(--line);background:var(--input);color:var(--text);padding:0 14px;outline:none}.dialog-input:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}.dialog-actions{display:flex;justify-content:flex-end;gap:10px}.dialog-actions .btn{min-width:84px}.dialog-actions .btn.red{background:#c62828}
@media(max-width:1320px){.chart-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:1320px){.chart-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@@ -153,7 +136,6 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
<p><span data-i18n="updated">Updated</span>: <span id="updatedAt">loading...</span></p> <p><span data-i18n="updated">Updated</span>: <span id="updatedAt">loading...</span></p>
</div> </div>
<div class="topbar-right"> <div class="topbar-right">
<button id="pushEnableBtn" class="btn secondary" type="button">Push</button>
<button id="wakeLockBtn" class="btn secondary" type="button">WakeLock</button> <button id="wakeLockBtn" class="btn secondary" type="button">WakeLock</button>
<button id="translateBtn" class="btn secondary" type="button">Translate</button> <button id="translateBtn" class="btn secondary" type="button">Translate</button>
<button id="themeBtn" class="btn secondary" type="button">Theme</button> <button id="themeBtn" class="btn secondary" type="button">Theme</button>
@@ -289,19 +271,6 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
</div> </div>
</div> </div>
<div class="spike-log-box">
<div class="push-device-head">
<h3 data-i18n="pushDevices">Push Devices</h3>
<div class="push-device-actions">
<button class="btn secondary" id="pushHealthcheckBtn" type="button" data-i18n="healthCheck">Health Check</button>
</div>
</div>
<div id="pushStatus" class="spike-log-empty" data-i18n="pushStatusChecking">Push status checking...</div>
<div id="pushDeviceList" class="push-device-list">
<div class="spike-log-empty" data-i18n="noPushDevices">No push devices.</div>
</div>
</div>
<div class="spike-log-box"> <div class="spike-log-box">
<div class="dmesg-head"> <div class="dmesg-head">
<h3>dmesg</h3> <h3>dmesg</h3>
@@ -326,7 +295,6 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
</div> </div>
<script src="/assets/app.js?v=20260618_throttling2"></script> <script src="/assets/app.js?v=20260618_throttling2"></script>
<script src="/assets/wakelock.js?v=20260607_prefs1"></script> <script src="/assets/wakelock.js?v=20260607_prefs1"></script>
<script src="/push_subscribe.js?v=20260607_prefs1"></script>
<?php endif; ?> <?php endif; ?>
</body> </body>
</html> </html>
-351
View File
@@ -1,351 +0,0 @@
(() => {
'use strict';
const button = document.querySelector('#pushEnableBtn');
const publicKey = document.querySelector('meta[name="vapid-public-key"]')?.content || '';
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
const pushDeviceNameStorageKey = 'controlPushDeviceName';
const pushDisabledStorageKey = 'controlPushDisabled';
let pushAutoRepairRunning = false;
function tr(key, fallback) {
return typeof window.controlT === 'function' ? window.controlT(key) : fallback;
}
function setButton(text, disabled = false, active = false) {
if (!button) return;
button.textContent = text || 'Push';
button.disabled = disabled;
button.dataset.active = active ? '1' : '0';
}
function publishPushStatus(detail) {
window.dispatchEvent(new CustomEvent('pushstatus:update', {
detail: Object.assign({
supported: ('serviceWorker' in navigator) && ('PushManager' in window) && ('Notification' in window),
permission: ('Notification' in window) ? Notification.permission : 'unsupported',
browser_subscription: false,
server_subscription: false,
server_checked: false,
manual_disabled: localStorage.getItem(pushDisabledStorageKey) === '1',
}, detail || {}),
}));
}
function hangulCount(value) {
return (String(value || '').match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || []).length;
}
async function deviceNameFromUser() {
const promptFn = window.customPrompt || (() => Promise.resolve(null));
const name = await promptFn(tr('pushDeviceNamePrompt', 'Enter a device name. Korean names require at least 2 Hangul characters.'), {
title: tr('pushRegisterTitle', 'Push Register'),
okText: tr('register', 'Register'),
placeholder: tr('phone', 'Phone'),
autocomplete: 'off',
});
if (name === null) {
return null;
}
const trimmed = String(name || '').trim();
if (hangulCount(trimmed) < 2) {
throw new Error(tr('pushDeviceNameInvalid', 'Device name must contain at least 2 Hangul characters.'));
}
return trimmed;
}
function rememberDeviceName(deviceName) {
const trimmed = String(deviceName || '').trim();
if (hangulCount(trimmed) >= 2) {
localStorage.setItem(pushDeviceNameStorageKey, trimmed);
localStorage.removeItem(pushDisabledStorageKey);
}
return trimmed;
}
function savedDeviceName() {
const stored = String(localStorage.getItem(pushDeviceNameStorageKey) || '').trim();
return hangulCount(stored) >= 2 ? stored : tr('autoRepair', 'Auto Repair');
}
function urlBase64ToUint8Array(value) {
const padding = '='.repeat((4 - value.length % 4) % 4);
const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
const output = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) {
output[i] = raw.charCodeAt(i);
}
return output;
}
async function postForm(action, body) {
const fd = new URLSearchParams();
Object.entries(body).forEach(([key, value]) => fd.append(key, String(value)));
fd.append('action', action);
fd.append('csrf', csrf);
const res = await fetch('/api.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': csrf,
},
body: fd.toString(),
});
const json = await res.json();
if (!res.ok || !json.ok) {
throw new Error(json?.message || json?.error || 'push_request_failed');
}
return json.data;
}
async function saveSubscription(subscription, deviceName) {
const payload = subscription.toJSON();
payload.device_name = rememberDeviceName(deviceName);
const res = await fetch('/api/save_subscription.php', {
method: 'POST',
credentials: 'same-origin',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
},
body: JSON.stringify(payload),
});
const json = await res.json();
if (!res.ok || !json.ok) {
throw new Error(json?.message || json?.error || 'subscription_save_failed');
}
}
async function registration() {
const reg = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
updateViaCache: 'none',
});
await reg.update().catch(() => {});
return reg;
}
async function currentSubscription() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return null;
}
const existing = await navigator.serviceWorker.getRegistration('/');
if (existing) {
await existing.update().catch(() => {});
}
return existing ? existing.pushManager.getSubscription() : null;
}
async function subscribePush(reg) {
return reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
}
async function pushServerStatus(subscription) {
const endpoint = subscription ? subscription.endpoint : '';
const res = await fetch('/api.php?action=push_status&endpoint=' + encodeURIComponent(endpoint), {
credentials: 'same-origin',
cache: 'no-store',
headers: {
'X-CSRF-Token': csrf,
},
});
const json = await res.json();
if (!res.ok || !json.ok) {
throw new Error(json?.message || json?.error || 'push_status_failed');
}
return json.data || {};
}
async function repairSubscriptionIfNeeded() {
if (pushAutoRepairRunning) return;
if (!publicKey) return;
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return;
if (Notification.permission !== 'granted') return;
if (localStorage.getItem(pushDisabledStorageKey) === '1') return;
pushAutoRepairRunning = true;
try {
const reg = await registration();
let subscription = await reg.pushManager.getSubscription();
if (!subscription) {
subscription = await subscribePush(reg);
await saveSubscription(subscription, savedDeviceName());
await refreshButton();
window.dispatchEvent(new CustomEvent('pushdevices:refresh'));
return;
}
const status = await pushServerStatus(subscription);
publishPushStatus({
browser_subscription: true,
server_subscription: status.subscribed === true,
server_checked: true,
});
if (status.device_name) {
rememberDeviceName(status.device_name);
}
if (status.subscribed) {
await refreshButton();
return;
}
await saveSubscription(subscription, savedDeviceName());
publishPushStatus({
browser_subscription: true,
server_subscription: true,
server_checked: true,
});
await refreshButton();
window.dispatchEvent(new CustomEvent('pushdevices:refresh'));
} catch (error) {
console.warn('push auto repair failed', error);
} finally {
pushAutoRepairRunning = false;
}
}
async function refreshButton() {
if (!button) return;
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) {
setButton('Push', true, false);
publishPushStatus({ supported: false });
return;
}
const subscription = await currentSubscription();
if (subscription) {
setButton('Push', false, true);
publishPushStatus({
browser_subscription: true,
});
} else if (Notification.permission === 'denied') {
setButton('Push', true, false);
publishPushStatus({
browser_subscription: false,
});
} else {
setButton('Push', false, false);
publishPushStatus({
browser_subscription: false,
});
}
}
async function subscribe() {
if (!publicKey) {
setButton('Push', true, false);
return;
}
const deviceName = await deviceNameFromUser();
if (deviceName === null) {
await refreshButton();
return;
}
setButton('Push', true, false);
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
setButton('Push', false, false);
return;
}
const reg = await registration();
let subscription = await reg.pushManager.getSubscription();
if (!subscription) {
subscription = await subscribePush(reg);
}
await saveSubscription(subscription, deviceName);
publishPushStatus({
browser_subscription: true,
server_subscription: true,
server_checked: true,
manual_disabled: false,
});
await refreshButton();
window.dispatchEvent(new CustomEvent('pushdevices:refresh'));
}
async function unsubscribe() {
const confirmFn = window.customConfirm || (() => Promise.resolve(false));
if (!await confirmFn(tr('pushDeletePrompt', 'Delete this browser Push subscription?'), {
title: tr('pushDeleteTitle', 'Delete Push'),
okText: tr('delete', 'Delete'),
danger: true,
})) {
await refreshButton();
return;
}
const subscription = await currentSubscription();
if (!subscription) {
await refreshButton();
return;
}
setButton('Push', true, true);
localStorage.setItem(pushDisabledStorageKey, '1');
await subscription.unsubscribe();
await postForm('delete_push_endpoint', {
endpoint: subscription.endpoint,
});
publishPushStatus({
browser_subscription: false,
server_subscription: false,
server_checked: true,
manual_disabled: true,
});
await refreshButton();
window.dispatchEvent(new CustomEvent('pushdevices:refresh'));
}
if (!button) return;
button.addEventListener('click', () => {
const active = button.dataset.active === '1';
const job = active ? unsubscribe() : subscribe();
job.catch(error => {
const alertFn = window.customAlert || (() => Promise.resolve());
alertFn(error.message || 'Push failed', {
title: tr('pushErrorTitle', 'Push Error'),
danger: true,
});
refreshButton().catch(() => {});
});
});
refreshButton().catch(() => setButton('Push', false, false));
repairSubscriptionIfNeeded();
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
repairSubscriptionIfNeeded();
}
});
setInterval(repairSubscriptionIfNeeded, 5 * 60 * 1000);
})();
-123
View File
@@ -1,123 +0,0 @@
async function currentEndpoint() {
const subscription = await self.registration.pushManager.getSubscription();
return subscription ? subscription.endpoint : '';
}
async function windowClientCount() {
const allClients = await clients.matchAll({
type: 'window',
includeUncontrolled: true,
});
return allClients.length;
}
function logPushEvent(eventName, payload = {}, extra = {}) {
return Promise.all([
currentEndpoint().catch(() => ''),
windowClientCount().catch(() => 0),
]).then(([endpoint, clientCount]) => fetch('/api/push_event.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
cache: 'no-store',
body: JSON.stringify({
event: eventName,
endpoint,
push_id: payload.push_id || '',
tag: payload.tag || '',
client_count: clientCount,
meta: extra,
}),
})).catch(() => {});
}
self.addEventListener('push', event => {
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch (e) {
payload = {
body: event.data ? event.data.text() : '',
};
}
const title = payload.title || 'Seoul Control Center';
const vibrate = Array.isArray(payload.vibrate)
? payload.vibrate.map(value => Number(value)).filter(value => Number.isFinite(value) && value >= 0)
: undefined;
const options = {
body: payload.body || 'System notice detected',
icon: '/assets/icon-192.png',
badge: '/assets/icon-192.png',
tag: payload.tag || 'control-push',
renotify: payload.renotify === true,
requireInteraction: payload.require_interaction === true || payload.requireInteraction === true,
silent: payload.silent === true,
vibrate,
data: {
url: payload.url || '/',
push_id: payload.push_id || '',
tag: payload.tag || 'control-push',
},
};
event.waitUntil((async () => {
await logPushEvent('push_received', payload);
try {
await self.registration.showNotification(title, options);
await logPushEvent('notification_shown', payload);
} catch (error) {
await logPushEvent('notification_show_failed', payload, {
reason: error && error.message ? error.message : 'show_failed',
});
throw error;
}
})());
});
self.addEventListener('notificationclick', event => {
event.notification.close();
const url = event.notification.data?.url || '/';
const payload = {
push_id: event.notification.data?.push_id || '',
tag: event.notification.data?.tag || event.notification.tag || '',
};
event.waitUntil((async () => {
await logPushEvent('notification_click', payload, {
action: event.action || '',
});
const allClients = await clients.matchAll({
type: 'window',
includeUncontrolled: true,
});
for (const client of allClients) {
if ('focus' in client) {
await client.focus();
if ('navigate' in client) {
return client.navigate(url);
}
return;
}
}
if (clients.openWindow) {
return clients.openWindow(url);
}
})());
});
self.addEventListener('notificationclose', event => {
const payload = {
push_id: event.notification.data?.push_id || '',
tag: event.notification.data?.tag || event.notification.tag || '',
};
event.waitUntil(logPushEvent('notification_close', payload));
});