Improve Home Assistant notifications
This commit is contained in:
@@ -20,7 +20,9 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
- System Notice와 process CPU/MEM 후보 표시
|
- System Notice와 process CPU/MEM 후보 표시
|
||||||
- dmesg 로그 열람
|
- dmesg 로그 열람
|
||||||
- Home Assistant webhook 기반 Android 모바일 알림
|
- Home Assistant webhook 기반 Android 모바일 알림
|
||||||
- 배터리 SOC 20% 이하 경고를 동일 `tag`로 갱신해 알림이 과도하게 쌓이지 않도록 처리
|
- 배터리 SOC 20% 이하 경고를 20/15/10/5% 단계로 구분하고 동일 `tag`로 갱신해 알림이 과도하게 쌓이지 않도록 처리
|
||||||
|
- 배터리 SOC가 20%를 초과해 복구되면 같은 `tag`의 Android 알림을 `clear_notification`으로 제거
|
||||||
|
- 최근 HA 알림 발송 성공/실패 이력을 대시보드에 표시
|
||||||
- WakeLock 버튼으로 대시보드 화면 꺼짐 방지
|
- WakeLock 버튼으로 대시보드 화면 꺼짐 방지
|
||||||
- Reboot 버튼으로 2단계 확인 후 시스템 재부팅 요청
|
- Reboot 버튼으로 2단계 확인 후 시스템 재부팅 요청
|
||||||
- 기본 브라우저 alert/confirm/prompt 대신 대시보드 디자인에 맞춘 custom dialog 사용
|
- 기본 브라우저 alert/confirm/prompt 대신 대시보드 디자인에 맞춘 custom dialog 사용
|
||||||
@@ -46,6 +48,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
- `config/config.php`: DB, 인증, CSRF, HA 알림, shell 실행 공통 함수
|
- `config/config.php`: DB, 인증, CSRF, HA 알림, shell 실행 공통 함수
|
||||||
- `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/ha_notify_channel.php`: Android 알림 채널 제거 명령 전송용 운영 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 예시
|
||||||
|
|
||||||
@@ -59,23 +62,29 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
- `system_notice_logs`: notice 발생 이력
|
- `system_notice_logs`: notice 발생 이력
|
||||||
- `wifi_observed_sessions`: 5G WiFi client의 최초/마지막 감지 시간
|
- `wifi_observed_sessions`: 5G WiFi client의 최초/마지막 감지 시간
|
||||||
- `ha_notify_logs`: HA webhook 알림 발송 성공/실패 이력, 대상 서버, HTTP code, tag, 메타 정보
|
- `ha_notify_logs`: HA webhook 알림 발송 성공/실패 이력, 대상 서버, HTTP code, tag, 메타 정보
|
||||||
- `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 배터리 설정, 선택적 HA 알림 설정
|
- `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 배터리 설정, HA 알림 설정
|
||||||
- 배터리 용량 설정은 `/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`를 사용합니다.
|
||||||
|
|
||||||
## HA 알림 구조
|
## HA 알림 구조
|
||||||
|
|
||||||
Control은 브라우저 Web Push/VAPID 구독을 사용하지 않습니다. 서버가 Home Assistant webhook으로 알림 payload를 보내고, HA 자동화가 `notify.mobile_app_seocaegeonyi_z_fold7` 서비스로 Android 알림을 전달합니다.
|
Control은 서버에서 Home Assistant webhook으로 알림 payload를 보내고, HA 자동화가 `notify.mobile_app_seocaegeonyi_z_fold7` 서비스로 Android 알림을 전달합니다.
|
||||||
|
|
||||||
- 기본 webhook id: `control_notify_20260621`
|
- webhook id: `/home/seo/secret/control.php`의 `ha_notify.webhook_id`
|
||||||
- 기본 발송 순서: 서울 HA `https://ha.seoul.chaegeon.com` 우선, 실패 시 목포 HA `https://ha.chaegeon.com`
|
- 기본 발송 순서: 서울 HA `https://ha.seoul.chaegeon.com` 우선, 실패 시 목포 HA `https://ha.chaegeon.com`
|
||||||
- payload 형식: `title`, `message`, `data`
|
- payload 형식: `title`, `message`, `data`
|
||||||
- Android 알림 속성은 `data`에 넣어 HA mobile app으로 전달
|
- Android 알림 속성은 `data`에 넣어 HA mobile app으로 전달
|
||||||
- 사용 속성: `tag`, `group`, `channel`, `importance`, `priority`, `ttl`, `sticky`, `persistent`, `renotify`, `color`, `notification_icon`, `vibrationPattern`, `ledColor`, `visibility`, `timeout`, `clickAction`, `url`, `actions`
|
- 사용 속성: `tag`, `group`, `channel`, `importance`, `priority`, `ttl`, `sticky`, `persistent`, `renotify`, `color`, `notification_icon`, `vibrationPattern`, `ledColor`, `visibility`, `timeout`, `clickAction`, `url`, `actions`
|
||||||
- 발송 결과는 `ha_notify_logs`에 저장
|
- 발송 결과는 `ha_notify_logs`에 저장
|
||||||
|
|
||||||
배터리 SOC 20% 이하 경고는 `control-battery-low` tag를 사용합니다. SOC 값이 조금씩 달라져도 Android 알림은 같은 tag로 갱신되어 알림 목록에 여러 장이 계속 쌓이지 않습니다. 경고 상태가 유지되면 짧은 쿨다운 뒤 다시 발송될 수 있습니다.
|
배터리 SOC 20% 이하 경고는 `control-battery-low` tag를 사용합니다. SOC 값이 조금씩 달라져도 Android 알림은 같은 tag로 갱신되어 알림 목록에 여러 장이 계속 쌓이지 않습니다. 20% 이하에서는 `주의`, 15% 이하에서는 `경고`, 10% 이하에서는 `위험`, 5% 이하에서는 `긴급` 단계로 채널, 중요도, 아이콘, 진동 패턴을 조정합니다. SOC가 20%를 초과하면 `clear_notification`으로 해당 tag 알림을 제거합니다.
|
||||||
|
|
||||||
System Notice는 `control-system-notice` tag를 사용하며, 10분 쿨다운으로 급격한 팬/온도 변화 알림을 제한합니다.
|
System Notice는 `control-system-notice` tag를 사용하며, 기본 10분 쿨다운으로 급격한 팬/온도 변화 알림을 제한합니다. 쿨다운은 `/home/seo/secret/control.php`의 `ha_notify.cooldowns`에서 조정할 수 있습니다.
|
||||||
|
|
||||||
|
Android 알림 채널은 생성 후 휴대폰 설정에 의해 중요도와 진동 패턴이 고정될 수 있습니다. 채널을 다시 만들 필요가 있을 때는 운영 CLI로 제거 명령을 보낸 뒤 다음 알림에서 재생성합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/usr/bin/php /var/www/control/bin/ha_notify_channel.php --remove=control_battery
|
||||||
|
```
|
||||||
|
|
||||||
## WiFi 관측 자동화
|
## WiFi 관측 자동화
|
||||||
|
|
||||||
@@ -109,6 +118,7 @@ control-wifi-observe.service
|
|||||||
9. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다.
|
9. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다.
|
||||||
10. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다.
|
10. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다.
|
||||||
11. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다.
|
11. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다.
|
||||||
|
12. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`에서 읽어 표시합니다.
|
||||||
|
|
||||||
## 주요 함수/모듈
|
## 주요 함수/모듈
|
||||||
|
|
||||||
@@ -121,7 +131,11 @@ control-wifi-observe.service
|
|||||||
- `apply_observed_wifi_connected_time()`: 5G WiFi 연결 시간이 없는 client에 서버 관측 경과 시간 적용
|
- `apply_observed_wifi_connected_time()`: 5G WiFi 연결 시간이 없는 client에 서버 관측 경과 시간 적용
|
||||||
- `send_ha_notify()`: HA webhook 알림 발송, 서울 우선/목포 fallback, 성공/실패 로그 저장
|
- `send_ha_notify()`: HA webhook 알림 발송, 서울 우선/목포 fallback, 성공/실패 로그 저장
|
||||||
- `ha_notify_due()`: 동일 tag 기준 쿨다운 판정
|
- `ha_notify_due()`: 동일 tag 기준 쿨다운 판정
|
||||||
|
- `clear_ha_notification()`: 같은 tag의 Android 알림 제거 명령 발송
|
||||||
|
- `battery_low_notify_profile()`: 배터리 SOC 단계별 채널, 중요도, 아이콘, 진동 정책 선택
|
||||||
- `send_battery_low_notify_if_needed()`: 배터리 SOC 20% 이하 알림 발송
|
- `send_battery_low_notify_if_needed()`: 배터리 SOC 20% 이하 알림 발송
|
||||||
|
- `ha_notify_log_rows()`: 대시보드 HA 알림 이력 조회
|
||||||
|
- `bin/ha_notify_channel.php`: Android 알림 채널 제거 명령 CLI
|
||||||
- `bin/wifi_observe.php`: 화면 접속 없이 `wifi_data()`를 호출해 5G 관측 세션을 선제 갱신
|
- `bin/wifi_observe.php`: 화면 접속 없이 `wifi_data()`를 호출해 5G 관측 세션을 선제 갱신
|
||||||
- `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어
|
- `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어
|
||||||
- `customAlert()`, `customConfirm()`, `customPrompt()`: 대시보드 공통 확인/입력 dialog
|
- `customAlert()`, `customConfirm()`, `customPrompt()`: 대시보드 공통 확인/입력 dialog
|
||||||
@@ -135,7 +149,7 @@ control-wifi-observe.service
|
|||||||
- sysfs 쓰기와 systemctl 실행 권한은 sudoers 범위로 제한해야 합니다.
|
- sysfs 쓰기와 systemctl 실행 권한은 sudoers 범위로 제한해야 합니다.
|
||||||
- 재부팅 API는 CSRF, 로그인 세션, 확인 단어 `재부팅`, 관리자 암호 재검증을 모두 요구합니다.
|
- 재부팅 API는 CSRF, 로그인 세션, 확인 단어 `재부팅`, 관리자 암호 재검증을 모두 요구합니다.
|
||||||
- 앱 비밀번호와 HA 알림 설정은 저장소 밖 secret 파일로 관리합니다.
|
- 앱 비밀번호와 HA 알림 설정은 저장소 밖 secret 파일로 관리합니다.
|
||||||
- HA webhook은 외부 요청을 받으므로 webhook id는 저장소에 노출되는 값보다 `/home/seo/secret/control.php`의 `ha_notify.webhook_id`로 교체해 운영할 수 있습니다.
|
- HA webhook id는 `/home/seo/secret/control.php`의 `ha_notify.webhook_id`에 둡니다. 값이 없으면 발송하지 않고 `ha_notify_logs`에 실패로 기록합니다.
|
||||||
|
|
||||||
## 운영 체크포인트
|
## 운영 체크포인트
|
||||||
|
|
||||||
@@ -148,7 +162,7 @@ control-wifi-observe.service
|
|||||||
- 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 로그를 확인합니다.
|
||||||
- HA 알림 자동화는 서울 `/home/seo/homeassistant/automations.yaml`, 목포 `/mnt/synology-docker/homeassistant/config/automations.yaml` 양쪽의 `CONTROL_HA_NOTIFY_WEBHOOK` 계열 항목에서 관리합니다.
|
- HA 알림 자동화는 서울 `/home/seo/homeassistant/automations.yaml`, 목포 `/mnt/synology-docker/homeassistant/config/automations.yaml` 양쪽의 `CONTROL_HA_NOTIFY_WEBHOOK` 계열 항목에서 관리합니다.
|
||||||
- 기존 `control-push-healthcheck.timer`와 `control-push-healthcheck.service`는 사용하지 않습니다.
|
- HA 알림 채널의 중요도나 진동이 예상과 다르면 휴대폰 알림 채널 설정을 확인하거나 `bin/ha_notify_channel.php`로 해당 채널 제거 명령을 보냅니다.
|
||||||
- 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` 값을 확인합니다.
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
|
$channel = '';
|
||||||
|
foreach (array_slice($argv, 1) as $arg) {
|
||||||
|
if (str_starts_with($arg, '--remove=')) {
|
||||||
|
$channel = trim(substr($arg, 9));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($channel === '') {
|
||||||
|
fwrite(STDERR, "Usage: php bin/ha_notify_channel.php --remove=control_battery\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = send_ha_notify([
|
||||||
|
'title' => 'HA 알림 채널 제거',
|
||||||
|
'message' => 'remove_channel',
|
||||||
|
'level' => 'info',
|
||||||
|
'tag' => 'control-channel-remove-' . preg_replace('/[^a-zA-Z0-9_-]+/', '-', $channel),
|
||||||
|
'data' => [
|
||||||
|
'channel' => $channel,
|
||||||
|
'notification_icon' => 'mdi:notification-clear-all',
|
||||||
|
'persistent' => false,
|
||||||
|
'sticky' => 'false',
|
||||||
|
'renotify' => 'false',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
|
||||||
|
exit(($result['sent'] ?? 0) > 0 ? 0 : 1);
|
||||||
+167
-44
@@ -22,7 +22,10 @@ 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'));
|
define('HA_NOTIFY_WEBHOOK_ID', (string)($controlHaNotifyConfig['webhook_id'] ?? ''));
|
||||||
|
define('HA_NOTIFY_BATTERY_LOW_COOLDOWN', max(1, (int)($controlHaNotifyConfig['cooldowns']['battery_low'] ?? 10)));
|
||||||
|
define('HA_NOTIFY_BATTERY_CLEAR_COOLDOWN', max(1, (int)($controlHaNotifyConfig['cooldowns']['battery_clear'] ?? 300)));
|
||||||
|
define('HA_NOTIFY_SYSTEM_NOTICE_COOLDOWN', max(1, (int)($controlHaNotifyConfig['cooldowns']['system_notice'] ?? 600)));
|
||||||
|
|
||||||
$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));
|
||||||
@@ -521,7 +524,7 @@ function ha_notify_default_data(string $level, string $tag, string $url = '/'):
|
|||||||
'tag' => $tag,
|
'tag' => $tag,
|
||||||
'group' => 'control',
|
'group' => 'control',
|
||||||
'sticky' => $isCritical ? 'true' : 'false',
|
'sticky' => $isCritical ? 'true' : 'false',
|
||||||
'persistent' => $isCritical ? 'true' : 'false',
|
'persistent' => $isCritical,
|
||||||
'renotify' => 'true',
|
'renotify' => 'true',
|
||||||
'color' => $isCritical ? '#ff3b30' : ($isWarning ? '#ffcc00' : '#3b82f6'),
|
'color' => $isCritical ? '#ff3b30' : ($isWarning ? '#ffcc00' : '#3b82f6'),
|
||||||
'notification_icon' => $isCritical ? 'mdi:alert-circle' : ($isWarning ? 'mdi:alert' : 'mdi:information'),
|
'notification_icon' => $isCritical ? 'mdi:alert-circle' : ($isWarning ? 'mdi:alert' : 'mdi:information'),
|
||||||
@@ -541,18 +544,62 @@ function ha_notify_default_data(string $level, string $tag, string $url = '/'):
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ha_notify_log_rows(int $limit = 20): array
|
||||||
|
{
|
||||||
|
$limit = max(1, min(100, $limit));
|
||||||
|
|
||||||
|
$stmt = db()->query("
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
created_at,
|
||||||
|
event,
|
||||||
|
target,
|
||||||
|
title,
|
||||||
|
tag,
|
||||||
|
http_code,
|
||||||
|
error,
|
||||||
|
meta
|
||||||
|
FROM ha_notify_logs
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT " . $limit . "
|
||||||
|
");
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($stmt->fetchAll() as $row) {
|
||||||
|
$meta = json_decode((string)($row['meta'] ?? ''), true);
|
||||||
|
$data = is_array($meta['data'] ?? null) ? $meta['data'] : [];
|
||||||
|
$rows[] = [
|
||||||
|
'id' => (int)$row['id'],
|
||||||
|
'created_at' => (string)$row['created_at'],
|
||||||
|
'event' => (string)$row['event'],
|
||||||
|
'target' => $row['target'],
|
||||||
|
'title' => $row['title'],
|
||||||
|
'tag' => $row['tag'],
|
||||||
|
'http_code' => $row['http_code'] === null ? null : (int)$row['http_code'],
|
||||||
|
'error' => $row['error'],
|
||||||
|
'level' => (string)($data['level'] ?? ''),
|
||||||
|
'channel' => (string)($data['channel'] ?? ''),
|
||||||
|
'icon' => (string)($data['notification_icon'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
function send_ha_notify(array $payload): array
|
function send_ha_notify(array $payload): array
|
||||||
{
|
{
|
||||||
$title = mb_substr((string)($payload['title'] ?? APP_NAME), 0, 255);
|
$title = mb_substr((string)($payload['title'] ?? APP_NAME), 0, 255);
|
||||||
$message = mb_substr((string)($payload['message'] ?? $payload['body'] ?? ''), 0, 4000);
|
$message = mb_substr((string)($payload['message'] ?? $payload['body'] ?? ''), 0, 4000);
|
||||||
$level = (string)($payload['level'] ?? 'info');
|
$level = (string)($payload['level'] ?? 'info');
|
||||||
$tag = mb_substr((string)($payload['tag'] ?? 'control-notify'), 0, 128);
|
$tag = mb_substr((string)($payload['tag'] ?? 'control-notify'), 0, 128);
|
||||||
|
$logTag = mb_substr((string)($payload['log_tag'] ?? $tag), 0, 128);
|
||||||
$url = (string)($payload['url'] ?? '/');
|
$url = (string)($payload['url'] ?? '/');
|
||||||
$data = ha_notify_default_data($level, $tag, $url);
|
$data = ha_notify_default_data($level, $tag, $url);
|
||||||
|
|
||||||
if (is_array($payload['data'] ?? null)) {
|
if (is_array($payload['data'] ?? null)) {
|
||||||
$data = array_replace_recursive($data, $payload['data']);
|
$data = array_replace_recursive($data, $payload['data']);
|
||||||
}
|
}
|
||||||
|
$data['level'] = $level;
|
||||||
|
|
||||||
$body = json_encode([
|
$body = json_encode([
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
@@ -560,6 +607,25 @@ function send_ha_notify(array $payload): array
|
|||||||
'data' => $data,
|
'data' => $data,
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
if (HA_NOTIFY_WEBHOOK_ID === '') {
|
||||||
|
ha_notify_log('send_failed', [
|
||||||
|
'target' => null,
|
||||||
|
'title' => $title,
|
||||||
|
'tag' => $logTag,
|
||||||
|
'http_code' => null,
|
||||||
|
'error' => 'ha_notify_webhook_missing',
|
||||||
|
'data' => $data,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'sent' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'target' => null,
|
||||||
|
'http_code' => null,
|
||||||
|
'error' => 'ha_notify_webhook_missing',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$attempts = 0;
|
$attempts = 0;
|
||||||
foreach (ha_notify_targets() as $target) {
|
foreach (ha_notify_targets() as $target) {
|
||||||
$name = (string)($target['name'] ?? 'ha');
|
$name = (string)($target['name'] ?? 'ha');
|
||||||
@@ -587,7 +653,7 @@ function send_ha_notify(array $payload): array
|
|||||||
ha_notify_log('send_success', [
|
ha_notify_log('send_success', [
|
||||||
'target' => $name,
|
'target' => $name,
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'tag' => $tag,
|
'tag' => $logTag,
|
||||||
'http_code' => $httpCode,
|
'http_code' => $httpCode,
|
||||||
'data' => $data,
|
'data' => $data,
|
||||||
]);
|
]);
|
||||||
@@ -603,7 +669,7 @@ function send_ha_notify(array $payload): array
|
|||||||
ha_notify_log('send_failed', [
|
ha_notify_log('send_failed', [
|
||||||
'target' => $name,
|
'target' => $name,
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'tag' => $tag,
|
'tag' => $logTag,
|
||||||
'http_code' => $httpCode,
|
'http_code' => $httpCode,
|
||||||
'error' => $error ?: ('HTTP ' . $httpCode),
|
'error' => $error ?: ('HTTP ' . $httpCode),
|
||||||
]);
|
]);
|
||||||
@@ -618,41 +684,85 @@ function send_ha_notify(array $payload): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function battery_soc_rising_from_history(float $percent): bool
|
function clear_ha_notification(string $tag, string $title = 'Control 알림 해제'): array
|
||||||
{
|
{
|
||||||
$stmt = db()->query("
|
return send_ha_notify([
|
||||||
SELECT battery_percent
|
'title' => $title,
|
||||||
FROM sensor_logs
|
'message' => 'clear_notification',
|
||||||
WHERE battery_percent IS NOT NULL
|
'level' => 'info',
|
||||||
ORDER BY id DESC
|
'tag' => $tag,
|
||||||
LIMIT 20
|
'log_tag' => $tag . '-clear',
|
||||||
");
|
'data' => [
|
||||||
$rows = array_reverse($stmt->fetchAll());
|
'tag' => $tag,
|
||||||
|
'channel' => 'control_status',
|
||||||
if (count($rows) < 8) {
|
'notification_icon' => 'mdi:bell-check',
|
||||||
return false;
|
'persistent' => false,
|
||||||
|
'sticky' => 'false',
|
||||||
|
'renotify' => 'false',
|
||||||
|
'timeout' => 1,
|
||||||
|
],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$values = [];
|
function battery_low_notify_profile(float $percent): array
|
||||||
foreach ($rows as $row) {
|
{
|
||||||
$value = $row['battery_percent'] ?? null;
|
if ($percent <= 5.0) {
|
||||||
if ($value === null || $value === '' || !is_numeric($value)) {
|
return [
|
||||||
continue;
|
'label' => '긴급',
|
||||||
}
|
'level' => 'critical',
|
||||||
$values[] = (float)$value;
|
'channel' => 'control_critical',
|
||||||
|
'importance' => 'max',
|
||||||
|
'icon' => 'mdi:battery-alert-variant-outline',
|
||||||
|
'color' => '#b91c1c',
|
||||||
|
'sticky' => 'true',
|
||||||
|
'persistent' => true,
|
||||||
|
'vibration' => '900,200,900,200,1400,250,1800',
|
||||||
|
'led' => 'red',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($values) < 8) {
|
if ($percent <= 10.0) {
|
||||||
return false;
|
return [
|
||||||
|
'label' => '위험',
|
||||||
|
'level' => 'critical',
|
||||||
|
'channel' => 'control_battery_critical',
|
||||||
|
'importance' => 'max',
|
||||||
|
'icon' => 'mdi:battery-10',
|
||||||
|
'color' => '#dc2626',
|
||||||
|
'sticky' => 'true',
|
||||||
|
'persistent' => true,
|
||||||
|
'vibration' => '900,250,900,250,1400',
|
||||||
|
'led' => 'red',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$older = array_slice($values, 0, 5);
|
if ($percent <= 15.0) {
|
||||||
$recent = array_slice($values, -5);
|
return [
|
||||||
$olderAvg = array_sum($older) / count($older);
|
'label' => '경고',
|
||||||
$recentAvg = array_sum($recent) / count($recent);
|
'level' => 'warning',
|
||||||
|
'channel' => 'control_battery_warning',
|
||||||
|
'importance' => 'high',
|
||||||
|
'icon' => 'mdi:battery-20',
|
||||||
|
'color' => '#f97316',
|
||||||
|
'sticky' => 'true',
|
||||||
|
'persistent' => true,
|
||||||
|
'vibration' => '600,200,900,200,900',
|
||||||
|
'led' => 'yellow',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return ($recentAvg - $olderAvg) >= 0.20
|
return [
|
||||||
&& ($percent - $olderAvg) >= 0.15;
|
'label' => '주의',
|
||||||
|
'level' => 'warning',
|
||||||
|
'channel' => 'control_battery',
|
||||||
|
'importance' => 'high',
|
||||||
|
'icon' => 'mdi:battery-alert',
|
||||||
|
'color' => '#f59e0b',
|
||||||
|
'sticky' => 'false',
|
||||||
|
'persistent' => false,
|
||||||
|
'vibration' => '250,150,250',
|
||||||
|
'led' => 'yellow',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function send_battery_low_notify_if_needed(array $battery): array
|
function send_battery_low_notify_if_needed(array $battery): array
|
||||||
@@ -671,7 +781,18 @@ function send_battery_low_notify_if_needed(array $battery): array
|
|||||||
? (float)$battery['voltage']
|
? (float)$battery['voltage']
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
$tag = 'control-battery-low';
|
||||||
if ($percent > 20.0) {
|
if ($percent > 20.0) {
|
||||||
|
$latestLow = latest_ha_notify_epoch_by_tag($tag);
|
||||||
|
$latestClear = latest_ha_notify_epoch_by_tag($tag . '-clear');
|
||||||
|
if (
|
||||||
|
$latestLow > 0
|
||||||
|
&& $latestLow > $latestClear
|
||||||
|
&& ha_notify_due($tag . '-clear', HA_NOTIFY_BATTERY_CLEAR_COOLDOWN)
|
||||||
|
) {
|
||||||
|
return clear_ha_notification($tag, '배터리 SOC 복구');
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'sent' => 0,
|
'sent' => 0,
|
||||||
'failed' => 0,
|
'failed' => 0,
|
||||||
@@ -679,8 +800,7 @@ function send_battery_low_notify_if_needed(array $battery): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$tag = 'control-battery-low';
|
if (!ha_notify_due($tag, HA_NOTIFY_BATTERY_LOW_COOLDOWN)) {
|
||||||
if (!ha_notify_due($tag, 10)) {
|
|
||||||
return [
|
return [
|
||||||
'sent' => 0,
|
'sent' => 0,
|
||||||
'failed' => 0,
|
'failed' => 0,
|
||||||
@@ -688,28 +808,31 @@ function send_battery_low_notify_if_needed(array $battery): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$profile = battery_low_notify_profile($percent);
|
||||||
$body = 'Battery SOC '
|
$body = 'Battery SOC '
|
||||||
. number_format($percent, 2)
|
. number_format($percent, 2)
|
||||||
. '%'
|
. '%'
|
||||||
. ($voltage === null ? '' : ' / ' . number_format($voltage, 3) . 'V')
|
. ($voltage === null ? '' : ' / ' . number_format($voltage, 3) . 'V')
|
||||||
. "\n20% 이하 상태입니다.";
|
. "\n"
|
||||||
|
. $profile['label']
|
||||||
|
. ' 단계입니다.';
|
||||||
|
|
||||||
return send_ha_notify([
|
return send_ha_notify([
|
||||||
'title' => '배터리 SOC 경고',
|
'title' => '배터리 SOC ' . $profile['label'],
|
||||||
'message' => $body,
|
'message' => $body,
|
||||||
'level' => 'critical',
|
'level' => $profile['level'],
|
||||||
'url' => '/',
|
'url' => '/',
|
||||||
'tag' => $tag,
|
'tag' => $tag,
|
||||||
'data' => [
|
'data' => [
|
||||||
'channel' => 'control_battery',
|
'channel' => $profile['channel'],
|
||||||
'notification_icon' => 'mdi:battery-alert',
|
'notification_icon' => $profile['icon'],
|
||||||
'color' => '#ff3b30',
|
'color' => $profile['color'],
|
||||||
'sticky' => 'true',
|
'sticky' => $profile['sticky'],
|
||||||
'persistent' => 'true',
|
'persistent' => $profile['persistent'],
|
||||||
'renotify' => 'true',
|
'renotify' => 'true',
|
||||||
'importance' => 'high',
|
'importance' => $profile['importance'],
|
||||||
'vibrationPattern' => '900,250,900,250,1400',
|
'vibrationPattern' => $profile['vibration'],
|
||||||
'ledColor' => 'red',
|
'ledColor' => $profile['led'],
|
||||||
'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),
|
||||||
],
|
],
|
||||||
|
|||||||
+3
-2
@@ -1983,14 +1983,14 @@ function collect_snapshot(bool $applyFan = true): array
|
|||||||
$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 ($latestNoticeNotify <= 0 || time() - $latestNoticeNotify >= 600) {
|
if ($latestNoticeNotify <= 0 || time() - $latestNoticeNotify >= HA_NOTIFY_SYSTEM_NOTICE_COOLDOWN) {
|
||||||
$fanSpike['notify'] = send_fan_spike_notify($fanSpike, $fan, $system, $processes);
|
$fanSpike['notify'] = send_fan_spike_notify($fanSpike, $fan, $system, $processes);
|
||||||
} else {
|
} else {
|
||||||
$fanSpike['notify'] = [
|
$fanSpike['notify'] = [
|
||||||
'sent' => 0,
|
'sent' => 0,
|
||||||
'failed' => 0,
|
'failed' => 0,
|
||||||
'skipped' => 'system_notice_cooldown',
|
'skipped' => 'system_notice_cooldown',
|
||||||
'cooldown_seconds' => 600,
|
'cooldown_seconds' => HA_NOTIFY_SYSTEM_NOTICE_COOLDOWN,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2010,6 +2010,7 @@ function collect_snapshot(bool $applyFan = true): array
|
|||||||
'processes' => $processes,
|
'processes' => $processes,
|
||||||
'fan_spike' => $fanSpike,
|
'fan_spike' => $fanSpike,
|
||||||
'fan_spike_history' => fan_spike_history(100),
|
'fan_spike_history' => fan_spike_history(100),
|
||||||
|
'ha_notify_history' => ha_notify_log_rows(20),
|
||||||
];
|
];
|
||||||
|
|
||||||
return $snapshot;
|
return $snapshot;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
statusBatterySoc: $('#statusBatterySoc'),
|
statusBatterySoc: $('#statusBatterySoc'),
|
||||||
statusBatteryRemaining: $('#statusBatteryRemaining'),
|
statusBatteryRemaining: $('#statusBatteryRemaining'),
|
||||||
spikeLogList: $('#spikeLogList'),
|
spikeLogList: $('#spikeLogList'),
|
||||||
|
haNotifyList: $('#haNotifyList'),
|
||||||
noticeBaseline: $('#noticeBaseline'),
|
noticeBaseline: $('#noticeBaseline'),
|
||||||
rebootBtn: $('#rebootBtn'),
|
rebootBtn: $('#rebootBtn'),
|
||||||
translateBtn: $('#translateBtn'),
|
translateBtn: $('#translateBtn'),
|
||||||
@@ -137,6 +138,10 @@
|
|||||||
systemNotice: 'System Notice',
|
systemNotice: 'System Notice',
|
||||||
noticeHistory: 'Notice History',
|
noticeHistory: 'Notice History',
|
||||||
noNoticeHistory: 'No system notice history.',
|
noNoticeHistory: 'No system notice history.',
|
||||||
|
haNotifyHistory: 'HA Notify History',
|
||||||
|
noHaNotifyHistory: 'No HA notify history.',
|
||||||
|
notifySuccess: 'success',
|
||||||
|
notifyFailed: 'failed',
|
||||||
show: 'Show',
|
show: 'Show',
|
||||||
hide: 'Hide',
|
hide: 'Hide',
|
||||||
translateButton: 'Translate',
|
translateButton: 'Translate',
|
||||||
@@ -255,6 +260,10 @@
|
|||||||
systemNotice: '시스템 알림',
|
systemNotice: '시스템 알림',
|
||||||
noticeHistory: '알림 이력',
|
noticeHistory: '알림 이력',
|
||||||
noNoticeHistory: '시스템 알림 이력이 없습니다.',
|
noNoticeHistory: '시스템 알림 이력이 없습니다.',
|
||||||
|
haNotifyHistory: 'HA 알림 이력',
|
||||||
|
noHaNotifyHistory: 'HA 알림 이력이 없습니다.',
|
||||||
|
notifySuccess: '성공',
|
||||||
|
notifyFailed: '실패',
|
||||||
show: '보기',
|
show: '보기',
|
||||||
hide: '숨기기',
|
hide: '숨기기',
|
||||||
translateButton: '번역',
|
translateButton: '번역',
|
||||||
@@ -997,6 +1006,32 @@
|
|||||||
: `<div class="spike-log-empty">${escapeHtml(t('noNoticeHistory'))}</div>`;
|
: `<div class="spike-log-empty">${escapeHtml(t('noNoticeHistory'))}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderHaNotifyHistory(rows = []) {
|
||||||
|
if (!els.haNotifyList) return;
|
||||||
|
|
||||||
|
els.haNotifyList.innerHTML = rows.length
|
||||||
|
? rows.map((row, index) => {
|
||||||
|
const ok = row.event === 'send_success';
|
||||||
|
const statusText = ok ? t('notifySuccess') : t('notifyFailed');
|
||||||
|
const detail = [
|
||||||
|
row.target ? `${t('hostLabel')}: ${row.target}` : '',
|
||||||
|
row.http_code ? `HTTP ${row.http_code}` : '',
|
||||||
|
row.channel || '',
|
||||||
|
row.error || '',
|
||||||
|
].filter(Boolean).join(' / ');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="spike-log-item ${index === 0 ? 'latest' : ''} ${ok ? '' : 'alert'}">
|
||||||
|
<strong>${escapeHtml(row.created_at || '-')} · ${escapeHtml(statusText)}</strong>
|
||||||
|
<span>${escapeHtml(row.title || '-')}</span>
|
||||||
|
<span>${escapeHtml(row.tag || '-')}</span>
|
||||||
|
${detail ? `<span>${escapeHtml(detail)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')
|
||||||
|
: `<div class="spike-log-empty">${escapeHtml(t('noHaNotifyHistory'))}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderFanCause(data) {
|
function renderFanCause(data) {
|
||||||
const processes = data.processes || {};
|
const processes = data.processes || {};
|
||||||
const baseline = data.fan_spike || {};
|
const baseline = data.fan_spike || {};
|
||||||
@@ -1007,6 +1042,7 @@
|
|||||||
? `${baselineTemp.toFixed(1)}°C / ${Math.round(baselineRpm).toLocaleString()} RPM · ${stateText}`
|
? `${baselineTemp.toFixed(1)}°C / ${Math.round(baselineRpm).toLocaleString()} RPM · ${stateText}`
|
||||||
: stateText;
|
: stateText;
|
||||||
renderSpikeHistory(data.fan_spike_history || []);
|
renderSpikeHistory(data.fan_spike_history || []);
|
||||||
|
renderHaNotifyHistory(data.ha_notify_history || []);
|
||||||
setText(els.noticeBaseline, baselineText);
|
setText(els.noticeBaseline, baselineText);
|
||||||
|
|
||||||
renderProcessRows(els.processCpuTable, processes.cpu || [], 'cpu');
|
renderProcessRows(els.processCpuTable, processes.cpu || [], 'cpu');
|
||||||
|
|||||||
+8
-1
@@ -105,7 +105,7 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
|
|||||||
.status-list{display:grid;gap:10px}.status-row{display:grid;grid-template-columns:112px minmax(0,1fr);gap:12px;align-items:baseline;padding:10px 12px;background:var(--card2);border-radius:14px}.status-key{color:var(--sub);font-size:13px}.status-value{overflow:visible;white-space:normal;word-break:break-word;font-variant-numeric:tabular-nums}
|
.status-list{display:grid;gap:10px}.status-row{display:grid;grid-template-columns:112px minmax(0,1fr);gap:12px;align-items:baseline;padding:10px 12px;background:var(--card2);border-radius:14px}.status-key{color:var(--sub);font-size:13px}.status-value{overflow:visible;white-space:normal;word-break:break-word;font-variant-numeric:tabular-nums}
|
||||||
.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.alert{border-color:rgba(255,95,87,.58);background:rgba(150,38,38,.18)}.spike-log-item.alert strong{color:#ffb4ad}.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}
|
.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}
|
||||||
@@ -271,6 +271,13 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="spike-log-box">
|
||||||
|
<h3 data-i18n="haNotifyHistory">HA Notify History</h3>
|
||||||
|
<div id="haNotifyList" class="spike-log-list">
|
||||||
|
<div class="spike-log-empty" data-i18n="noHaNotifyHistory">No HA notify history.</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user