diff --git a/README.md b/README.md index f9dc05e..88346f2 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `/etc/systemd/system/*.service` 기준 사용자 생성 서비스의 서비스명, 현재 상태, enable 상태, PID, restart 횟수, 최근 journal 로그를 진단 접힘 영역에서 표시 - 사용자 서비스 로그는 서비스별 최근 500줄까지 조회하고, 사용자가 서비스 목록을 스크롤하는 동안에는 자동 갱신이 화면 위치를 되돌리지 않습니다. - 라즈베리파이와 시놀로지 사이 원격 syslog 송수신 상태, 영구 journal, 커널 크래시 보존 상태를 진단 접힘 영역에서 표시 -- dmesg 로그는 진단 접힘 영역에서 필요할 때 열람 +- 시놀로지에서 라즈베리파이로 들어온 원격 로그와 dmesg 로그는 진단 접힘 영역에서 필요할 때 열람 - WakeLock 버튼으로 대시보드 화면 꺼짐 방지 - Reboot 버튼으로 2단계 확인 후 시스템 재부팅 요청 - 기본 브라우저 alert/confirm/prompt 대신 대시보드 디자인에 맞춘 custom dialog 사용 @@ -51,6 +51,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `public/api.php?action=settings_reset`: 설정 모달 값을 기본값으로 초기화 - `public/api.php?action=touch_display_save`: `/boot/firmware/config.txt`, `/boot/firmware/cmdline.txt`에 DSI 터치 디스플레이 부팅 설정 저장 - `public/api.php?action=touch_display_runtime_save`: DSI 터치 디스플레이 백라이트 밝기와 백라이트 전원 즉시 적용 +- `public/api.php?action=synology_log`: 라즈베리파이에 저장된 시놀로지 원격 로그 최근 500줄 조회 - `public/api.php?action=dmesg`: dmesg 로그 조회 - `public/api.php?action=reboot`: 확인 단어와 관리자 암호 재검증 후 sudo reboot 실행 @@ -90,7 +91,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 라즈베리파이는 `rsyslog`와 `rsyslog-gnutls`로 로컬 journal/syslog를 시놀로지 Log Center에 TCP/TLS 6514로 보냅니다. 전송 대상은 `chaegeon.com:6514`이며, 네트워크가 끊기면 `/var/spool/rsyslog/control_synology_fwd*` 큐에 남았다가 재시도합니다. -시놀로지는 DSM `syslog-ng`에서 라즈베리파이 `121.157.202.229:6514`로 TCP/TLS syslog를 보냅니다. 라즈베리파이 수신 로그는 `/var/log/remote/synology/*.log`에 저장하고 `/etc/logrotate.d/control-remote-syslog`가 매일 회전합니다. +시놀로지는 DSM `syslog-ng`에서 라즈베리파이 `121.157.202.229:6514`로 TCP/TLS syslog를 보냅니다. 라즈베리파이 수신 로그는 `/var/log/remote/synology/*.log`에 저장하고 `/etc/logrotate.d/control-remote-syslog`가 매일 회전합니다. Control 진단 패널의 시놀로지 로그 뷰어는 `/var/log/remote/synology/chaegeon.log` 최근 500줄을 최신순으로 보여주며, WebSocket 또는 HTTP fallback으로 1초마다 갱신합니다. 사용자가 로그 영역을 스크롤하는 동안에는 자동 갱신을 잠깐 멈춰 화면 위치가 튀지 않게 합니다. 커널 패닉이나 강제 재부팅 분석을 위해 `systemd-journald`는 `/var/log/journal`에 영구 저장합니다. `dtoverlay=ramoops` 기반 pstore는 부팅 직후 `control-pstore-collect.service`가 `/var/log/pstore`로 복사합니다. 현재 pstore에 남은 크래시 조각이 없으면 이 서비스는 조건 불충족으로 `inactive`일 수 있으며 정상입니다. @@ -127,7 +128,7 @@ API 응답의 `battery.enabled`는 false, `battery.removed`는 true로 내려가 - WebSocket 끊김 시 HTTP fallback 상태 갱신: 2초마다 - 탭 복귀: 즉시 1회 상태 갱신 - UPS/배터리 잔여 시간: 현재 운영 구성에서는 표시하지 않음 -- dmesg: 진단 패널에서 열었을 때만 1초마다 갱신, 닫으면 중지 +- 시놀로지 원격 로그/dmesg: 진단 패널에서 열었을 때만 1초마다 갱신, 닫으면 중지 - WiFi 관측 보정 timer: 10초마다 `bin/wifi_observe.php` 실행 - 외출/귀가 팬 정책: 팬 정책 적용 루프에서 기본 30초마다 서울 HA의 휴대폰 device tracker를 확인 - WebSocket 소스 변경 감지: 15초마다 확인 후 재시작 @@ -187,7 +188,7 @@ control-wifi-observe.service 13. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다. 14. UPS/배터리 장치가 제거되어 배터리 센서 조회와 잔여 시간 학습 계산은 기본적으로 돌리지 않습니다. 15. System Notice 조건이 맞으면 Control이 `system_notice_logs`에 이력을 저장합니다. -16. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, dmesg는 접힘 영역으로 분리합니다. +16. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, 시놀로지 원격 로그, dmesg는 접힘 영역으로 분리합니다. ## 팬 판단 온도 diff --git a/bin/control_ws.php b/bin/control_ws.php index 3edb1c6..560da48 100644 --- a/bin/control_ws.php +++ b/bin/control_ws.php @@ -16,11 +16,12 @@ require_once __DIR__ . '/../public/api.php'; final class ControlWebSocket implements MessageComponentInterface { - /** @var SplObjectStorage */ + /** @var SplObjectStorage */ private SplObjectStorage $clients; private bool $statusBusy = false; private bool $dmesgBusy = false; + private bool $synologyLogBusy = false; public function __construct() { @@ -47,6 +48,7 @@ final class ControlWebSocket implements MessageComponentInterface $this->clients[$conn] = [ 'dmesg' => false, + 'synology_log' => false, 'ip' => $ip, ]; $this->sendStatus($conn); @@ -61,6 +63,7 @@ final class ControlWebSocket implements MessageComponentInterface $state = $this->clients[$from] ?? [ 'dmesg' => false, + 'synology_log' => false, 'ip' => '127.0.0.1', ]; @@ -73,6 +76,15 @@ final class ControlWebSocket implements MessageComponentInterface return; } + if (($data['type'] ?? '') === 'synology_log') { + $state['synology_log'] = !empty($data['open']); + $this->clients[$from] = $state; + if ($state['synology_log']) { + $this->sendSynologyLog($from); + } + return; + } + if (($data['type'] ?? '') === 'status_refresh') { $this->sendStatus($from); } @@ -147,6 +159,40 @@ final class ControlWebSocket implements MessageComponentInterface } } + public function broadcastSynologyLog(): void + { + if ($this->clients->count() === 0 || $this->synologyLogBusy) { + return; + } + + $targets = []; + foreach ($this->clients as $client) { + $state = $this->clients[$client]; + if (!empty($state['synology_log'])) { + $targets[] = $client; + } + } + + if ($targets === []) { + return; + } + + $this->synologyLogBusy = true; + try { + $payload = $this->json([ + 'type' => 'synology_log', + 'data' => synology_remote_log(), + ]); + foreach ($targets as $client) { + $client->send($payload); + } + } catch (Throwable $e) { + $this->broadcastError($e); + } finally { + $this->synologyLogBusy = false; + } + } + private function sendStatus(ConnectionInterface $conn): void { try { @@ -174,6 +220,21 @@ final class ControlWebSocket implements MessageComponentInterface } } + private function sendSynologyLog(ConnectionInterface $conn): void + { + try { + $conn->send($this->json([ + 'type' => 'synology_log', + 'data' => synology_remote_log(), + ])); + } catch (Throwable $e) { + $conn->send($this->json([ + 'type' => 'error', + 'message' => $e->getMessage(), + ])); + } + } + private function statusPayload(): string { try { @@ -280,6 +341,7 @@ $startedSignature = $watchSignature($watchFiles); $loop->addPeriodicTimer(1.0, static fn() => $app->broadcastStatus()); $loop->addPeriodicTimer(1.0, static fn() => $app->broadcastDmesg()); +$loop->addPeriodicTimer(1.0, static fn() => $app->broadcastSynologyLog()); $loop->addPeriodicTimer(15.0, static function () use ($watchFiles, $watchSignature, $startedSignature): void { if ($watchSignature($watchFiles) === $startedSignature) { return; diff --git a/public/api.php b/public/api.php index 61d7710..430354f 100644 --- a/public/api.php +++ b/public/api.php @@ -121,6 +121,58 @@ function dmesg_log(): array ]; } +function text_log_tail(string $path, string $label, int $limit = 500): array +{ + $limit = max(1, min(1000, $limit)); + $stat = sh(['/usr/bin/stat', '-c', '%s|%Y', $path], true, 3); + if ((int)$stat['code'] !== 0) { + return [ + 'path' => $path, + 'available' => false, + 'lines' => [], + 'line_count' => 0, + 'size_bytes' => 0, + 'updated_at' => null, + 'message' => $label . ' log is not available yet.', + ]; + } + + $parts = explode('|', trim((string)$stat['out']), 2); + $size = (int)($parts[0] ?? 0); + $mtime = (int)($parts[1] ?? 0); + $tail = sh(['/usr/bin/tail', '-n', (string)$limit, $path], true, 4); + if ((int)$tail['code'] !== 0) { + return [ + 'path' => $path, + 'available' => false, + 'lines' => [], + 'line_count' => 0, + 'size_bytes' => $size, + 'updated_at' => $mtime > 0 ? date('Y-m-d H:i:s', $mtime) : null, + 'message' => 'failed to open ' . $label . ' log.', + ]; + } + + $lines = array_values(array_filter( + preg_split('/\R/', trim((string)$tail['out'])) ?: [], + static fn($line): bool => trim((string)$line) !== '' + )); + + return [ + 'path' => $path, + 'available' => true, + 'lines' => array_reverse($lines), + 'line_count' => count($lines), + 'size_bytes' => $size, + 'updated_at' => $mtime > 0 ? date('Y-m-d H:i:s', $mtime) : null, + ]; +} + +function synology_remote_log(): array +{ + return text_log_tail('/var/log/remote/synology/chaegeon.log', 'synology remote'); +} + function command_first_line(array $cmd, bool $root = false, int $timeout = 3): string { $result = sh($cmd, $root, $timeout); @@ -4288,6 +4340,13 @@ function control_api_dispatch(): void ]); } + if ($action === 'synology_log') { + json_out([ + 'ok' => true, + 'data' => synology_remote_log(), + ]); + } + if ($action === 'status') { json_out([ 'ok' => true, diff --git a/public/assets/app.js b/public/assets/app.js index 2bde63a..bec9a2f 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -60,6 +60,9 @@ dmesgToggle: $('#dmesgToggle'), dmesgOutput: $('#dmesgOutput'), dmesgMeta: $('#dmesgMeta'), + synologyLogToggle: $('#synologyLogToggle'), + synologyLogOutput: $('#synologyLogOutput'), + synologyLogMeta: $('#synologyLogMeta'), processDetails: $('#processDetails'), diagnosticDetails: $('#diagnosticDetails'), }; @@ -79,6 +82,10 @@ dmesgOpen: false, dmesgTimer: null, dmesgLatestKey: null, + synologyLogOpen: false, + synologyLogTimer: null, + synologyLogLatestKey: null, + synologyLogScrollHoldUntil: 0, ws: null, wsConnected: false, wsFallbackTimer: null, @@ -281,6 +288,9 @@ fanUpdateFailed: 'fan update failed', dmesgUnavailable: 'dmesg log is not available yet.', dmesgRefreshFailed: 'dmesg refresh failed', + synologyLog: 'Synology Log', + synologyLogUnavailable: 'Synology log is not available yet.', + synologyLogRefreshFailed: 'Synology log refresh failed', rebootPrompt: 'Type reboot below to continue.', rebootPhrase: 'reboot', rebootTitle: 'System Reboot', @@ -473,6 +483,9 @@ fanUpdateFailed: '팬 갱신 실패', dmesgUnavailable: 'dmesg 로그를 아직 사용할 수 없습니다.', dmesgRefreshFailed: 'dmesg 갱신 실패', + synologyLog: '시놀로지 로그', + synologyLogUnavailable: '시놀로지 로그를 아직 사용할 수 없습니다.', + synologyLogRefreshFailed: '시놀로지 로그 갱신 실패', rebootPrompt: '재부팅을 계속하려면 아래에 재부팅을 입력하세요.', rebootPhrase: '재부팅', rebootTitle: '시스템 재부팅', @@ -527,6 +540,9 @@ if (els.dmesgToggle) { els.dmesgToggle.textContent = state.dmesgOpen ? t('hide') : t('show'); } + if (els.synologyLogToggle) { + els.synologyLogToggle.textContent = state.synologyLogOpen ? t('hide') : t('show'); + } } function initPreferences() { @@ -1163,6 +1179,10 @@ stopDmesgFallback(); sendWs({ type: 'dmesg', open: true }); } + if (state.synologyLogOpen) { + stopSynologyLogFallback(); + sendWs({ type: 'synology_log', open: true }); + } }); state.ws.addEventListener('message', event => { @@ -1184,6 +1204,11 @@ return; } + if (message?.type === 'synology_log') { + renderSynologyLog(message.data || {}); + return; + } + if (message?.type === 'error') { console.error(message.message || 'websocket error'); } @@ -1195,6 +1220,9 @@ if (state.dmesgOpen) { startDmesgFallback(); } + if (state.synologyLogOpen) { + startSynologyLogFallback(); + } const delay = state.wsReconnectDelay; state.wsReconnectDelay = Math.min(15000, state.wsReconnectDelay * 1.7); state.wsReconnectTimer = setTimeout(connectControlSocket, delay); @@ -2554,6 +2582,84 @@ } } + function scrollSynologyLogToTop() { + if (!els.synologyLogOutput) return; + if (els.synologyLogOutput.hidden) return; + if (Date.now() < state.synologyLogScrollHoldUntil) return; + + els.synologyLogOutput.scrollTop = 0; + } + + function renderSynologyLog(data) { + if (!els.synologyLogOutput || !els.synologyLogMeta) return; + if (Date.now() < state.synologyLogScrollHoldUntil) return; + + const scrollTop = els.synologyLogOutput.scrollTop; + + if (!data?.available) { + els.synologyLogOutput.textContent = data?.message || t('synologyLogUnavailable'); + els.synologyLogMeta.textContent = '/var/log/remote/synology/chaegeon.log unavailable'; + return; + } + + const lines = data.lines || []; + const latestKey = `${data.line_count || 0}\n${lines[0] || ''}`; + const hasNewLine = state.synologyLogLatestKey !== null && latestKey !== state.synologyLogLatestKey; + + els.synologyLogOutput.textContent = lines.join('\n'); + els.synologyLogMeta.textContent = `${data.path || '/var/log/remote/synology/chaegeon.log'} · ${data.line_count || 0} lines · updated ${data.updated_at || '-'}`; + state.synologyLogLatestKey = latestKey; + els.synologyLogOutput.scrollTop = Date.now() < state.synologyLogScrollHoldUntil ? scrollTop : 0; + + if (hasNewLine) { + requestAnimationFrame(scrollSynologyLogToTop); + } + } + + async function refreshSynologyLog() { + if (!state.synologyLogOpen) return; + + try { + renderSynologyLog(await api('synology_log')); + } catch (e) { + console.error(e); + if (els.synologyLogOutput) { + els.synologyLogOutput.textContent = e.message || t('synologyLogRefreshFailed'); + } + } + } + + function stopSynologyLogFallback() { + clearInterval(state.synologyLogTimer); + state.synologyLogTimer = null; + } + + function startSynologyLogFallback() { + if (state.synologyLogTimer) return; + + refreshSynologyLog(); + state.synologyLogTimer = setInterval(refreshSynologyLog, 1000); + } + + function setSynologyLogOpen(open) { + state.synologyLogOpen = open; + if (els.synologyLogOutput) { + els.synologyLogOutput.hidden = !open; + } + if (els.synologyLogToggle) { + els.synologyLogToggle.textContent = open ? t('hide') : t('show'); + } + + stopSynologyLogFallback(); + if (open) { + if (!sendWs({ type: 'synology_log', open: true })) { + startSynologyLogFallback(); + } + } else { + sendWs({ type: 'synology_log', open: false }); + } + } + async function refreshStatus() { if (state.loading) return; state.loading = true; @@ -2697,6 +2803,15 @@ els.dmesgToggle?.addEventListener('click', () => { setDmesgOpen(!state.dmesgOpen); }); + els.synologyLogToggle?.addEventListener('click', () => { + setSynologyLogOpen(!state.synologyLogOpen); + }); + const holdSynologyLogScroll = () => { + state.synologyLogScrollHoldUntil = Date.now() + 4000; + }; + ['scroll', 'wheel', 'touchstart', 'touchmove', 'pointerdown'].forEach(eventName => { + els.synologyLogOutput?.addEventListener(eventName, holdSynologyLogScroll, { passive: true }); + }); const holdCustomServiceScroll = () => { state.customServiceScrollHoldUntil = Date.now() + 4000; }; @@ -2776,6 +2891,9 @@ if (state.dmesgOpen && !sendWs({ type: 'dmesg', open: true })) { refreshDmesg(); } + if (state.synologyLogOpen && !sendWs({ type: 'synology_log', open: true })) { + refreshSynologyLog(); + } } }); document.addEventListener('keydown', event => { diff --git a/public/index.php b/public/index.php index 1c05812..affcc37 100644 --- a/public/index.php +++ b/public/index.php @@ -344,6 +344,15 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da} +
+
+

Synology Log

+ +
+
/var/log/remote/synology/chaegeon.log
+ +
+

dmesg