Use Home Assistant notifications
This commit is contained in:
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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,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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
+166
-632
@@ -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;
|
||||||
}
|
|
||||||
|
|
||||||
function push_subscription_from_json(string $raw): array
|
$configured = is_array($controlHaNotifyConfig['targets'] ?? null) ? $controlHaNotifyConfig['targets'] : [];
|
||||||
{
|
if ($configured !== []) {
|
||||||
$data = json_decode($raw, true);
|
return $configured;
|
||||||
|
|
||||||
return is_array($data) ? $data : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function push_log_event(string $event, array $context = []): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$endpoint = (string)($context['endpoint'] ?? '');
|
|
||||||
$endpointHash = (string)($context['endpoint_hash'] ?? '');
|
|
||||||
if ($endpointHash === '' && $endpoint !== '') {
|
|
||||||
$endpointHash = hash('sha256', $endpoint);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
['name' => 'seoul', 'base' => 'https://ha.seoul.chaegeon.com'],
|
||||||
|
['name' => 'main', 'base' => 'https://ha.chaegeon.com'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ha_notify_log(string $event, array $context = []): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ha_notify_default_data(string $level, string $tag, string $url = '/'): array
|
||||||
|
{
|
||||||
|
$level = in_array($level, ['critical', 'warning', 'info'], true) ? $level : 'info';
|
||||||
|
$isCritical = $level === 'critical';
|
||||||
|
$isWarning = $level === 'warning';
|
||||||
|
$absoluteUrl = str_starts_with($url, 'http://') || str_starts_with($url, 'https://')
|
||||||
|
? $url
|
||||||
|
: 'https://' . APP_HOST . '/' . ltrim($url, '/');
|
||||||
|
|
||||||
if (!$force && $latest > 0 && time() - $latest < $minHours * 3600) {
|
|
||||||
return [
|
return [
|
||||||
'sent' => 0,
|
'ttl' => 0,
|
||||||
|
'priority' => 'high',
|
||||||
|
'channel' => $isCritical ? 'control_critical' : ($isWarning ? 'control_warning' : 'control_status'),
|
||||||
|
'importance' => $isCritical ? 'max' : 'high',
|
||||||
|
'tag' => $tag,
|
||||||
|
'group' => 'control',
|
||||||
|
'sticky' => $isCritical ? 'true' : 'false',
|
||||||
|
'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 send_ha_notify(array $payload): array
|
||||||
|
{
|
||||||
|
$title = mb_substr((string)($payload['title'] ?? APP_NAME), 0, 255);
|
||||||
|
$message = mb_substr((string)($payload['message'] ?? $payload['body'] ?? ''), 0, 4000);
|
||||||
|
$level = (string)($payload['level'] ?? 'info');
|
||||||
|
$tag = mb_substr((string)($payload['tag'] ?? 'control-notify'), 0, 128);
|
||||||
|
$url = (string)($payload['url'] ?? '/');
|
||||||
|
$data = ha_notify_default_data($level, $tag, $url);
|
||||||
|
|
||||||
|
if (is_array($payload['data'] ?? null)) {
|
||||||
|
$data = array_replace_recursive($data, $payload['data']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = json_encode([
|
||||||
|
'title' => $title,
|
||||||
|
'message' => $message,
|
||||||
|
'data' => $data,
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
$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,
|
'failed' => 0,
|
||||||
|
'target' => $name,
|
||||||
|
'http_code' => $httpCode,
|
||||||
'error' => null,
|
'error' => null,
|
||||||
'skipped' => true,
|
|
||||||
'message' => 'healthcheck_cooldown',
|
|
||||||
'next_after_seconds' => ($minHours * 3600) - (time() - $latest),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return send_push_payload([
|
ha_notify_log('send_failed', [
|
||||||
'title' => 'Seoul Control Center',
|
'target' => $name,
|
||||||
'body' => '푸시 기기 상태 확인 알림입니다.',
|
'title' => $title,
|
||||||
'url' => '/',
|
'tag' => $tag,
|
||||||
'tag' => 'control-healthcheck-' . date('YmdHi'),
|
'http_code' => $httpCode,
|
||||||
'created_at' => date('Y-m-d H:i:s'),
|
'error' => $error ?: ('HTTP ' . $httpCode),
|
||||||
'silent' => true,
|
|
||||||
'data' => [
|
|
||||||
'kind' => 'push_healthcheck',
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
function reset_battery_low_push_state(): void
|
|
||||||
{
|
|
||||||
$stmt = db()->prepare("
|
|
||||||
INSERT INTO battery_push_state
|
|
||||||
(id, last_sent_at, last_percent, last_voltage)
|
|
||||||
VALUES
|
|
||||||
(1, NULL, NULL, NULL)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
last_sent_at = NULL,
|
|
||||||
last_percent = NULL,
|
|
||||||
last_voltage = NULL
|
|
||||||
");
|
|
||||||
$stmt->execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
function battery_low_push_due(float $percent, ?float $voltage): bool
|
|
||||||
{
|
|
||||||
$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("
|
return [
|
||||||
INSERT INTO battery_push_state
|
'sent' => 0,
|
||||||
(id, last_sent_at, last_percent, last_voltage)
|
'failed' => $attempts,
|
||||||
VALUES
|
'target' => null,
|
||||||
(1, CURRENT_TIMESTAMP, :last_percent, :last_voltage)
|
'http_code' => null,
|
||||||
ON DUPLICATE KEY UPDATE
|
'error' => 'ha_notify_failed',
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
@@ -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)));
|
||||||
|
|||||||
@@ -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]);
|
|
||||||
@@ -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,
|
|
||||||
]);
|
|
||||||
@@ -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),
|
|
||||||
]);
|
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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));
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user