원격 로그 상태 진단 추가

This commit is contained in:
seo
2026-07-25 19:08:05 +09:00
parent c2099c21f0
commit 10c00b3e57
4 changed files with 292 additions and 2 deletions
+9
View File
@@ -28,6 +28,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
- process CPU/MEM 후보는 프로세스 상세 접힘 영역에서 표시
- `/etc/systemd/system/*.service` 기준 사용자 생성 서비스의 서비스명, 현재 상태, enable 상태, PID, restart 횟수, 최근 journal 로그를 진단 접힘 영역에서 표시
- 사용자 서비스 로그는 서비스별 최근 500줄까지 조회하고, 사용자가 서비스 목록을 스크롤하는 동안에는 자동 갱신이 화면 위치를 되돌리지 않습니다.
- 라즈베리파이와 시놀로지 사이 원격 syslog 송수신 상태, 영구 journal, 커널 크래시 보존 상태를 진단 접힘 영역에서 표시
- dmesg 로그는 진단 접힘 영역에서 필요할 때 열람
- WakeLock 버튼으로 대시보드 화면 꺼짐 방지
- Reboot 버튼으로 2단계 확인 후 시스템 재부팅 요청
@@ -85,6 +86,14 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
- `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 선택적 배터리 설정, WOL 전용 API 토큰
- UPS/배터리 기능은 `/home/seo/secret/control.php``battery.enabled`가 명시적으로 true일 때만 동작합니다. 현재 운영 기준은 UPS 제거 상태이므로 기본값은 false입니다.
## 원격 로그
라즈베리파이는 `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`가 매일 회전합니다.
커널 패닉이나 강제 재부팅 분석을 위해 `systemd-journald``/var/log/journal`에 영구 저장합니다. `dtoverlay=ramoops` 기반 pstore는 부팅 직후 `control-pstore-collect.service``/var/log/pstore`로 복사합니다. 현재 pstore에 남은 크래시 조각이 없으면 이 서비스는 조건 불충족으로 `inactive`일 수 있으며 정상입니다.
## UPS/배터리 상태
UPS가 과부하 상황에서 전압 하강과 강제 종료를 반복해 현재 운영 구성에서는 제거되어 있습니다. 이에 따라 Control은 기본적으로 MAX17048 배터리 센서를 읽지 않고, 배터리 SOC/전압 차트와 잔여 시간 UI도 표시하지 않습니다.
+136
View File
@@ -121,6 +121,141 @@ function dmesg_log(): array
];
}
function command_first_line(array $cmd, bool $root = false, int $timeout = 3): string
{
$result = sh($cmd, $root, $timeout);
$lines = array_values(array_filter(
preg_split('/\R/', trim((string)$result['out'])) ?: [],
static fn($line): bool => trim((string)$line) !== ''
));
return (int)$result['code'] === 0 && $lines ? (string)$lines[0] : '';
}
function remote_log_file_status(string $dir): array
{
$files = [];
$totalBytes = 0;
$newest = null;
foreach (glob(rtrim($dir, '/') . '/*.log') ?: [] as $path) {
$size = filesize($path) ?: 0;
$mtime = filemtime($path) ?: 0;
$totalBytes += $size;
$row = [
'name' => basename($path),
'path' => $path,
'size_bytes' => $size,
'size' => bytes_human($size),
'updated_at' => $mtime > 0 ? date('Y-m-d H:i:s', $mtime) : null,
'age_seconds' => $mtime > 0 ? max(0, time() - $mtime) : null,
];
$files[] = $row;
if ($newest === null || (int)$row['age_seconds'] < (int)($newest['age_seconds'] ?? PHP_INT_MAX)) {
$newest = $row;
}
}
usort($files, static fn($a, $b): int => (int)($a['age_seconds'] ?? PHP_INT_MAX) <=> (int)($b['age_seconds'] ?? PHP_INT_MAX));
return [
'path' => $dir,
'available' => is_dir($dir),
'file_count' => count($files),
'total_bytes' => $totalBytes,
'total_size' => bytes_human($totalBytes),
'newest' => $newest,
'files' => array_slice($files, 0, 5),
];
}
function remote_log_queue_status(): array
{
$result = sh([
'/usr/bin/find',
'/var/spool/rsyslog',
'-maxdepth',
'1',
'-name',
'control_synology_fwd*',
'-printf',
'%s\n',
], true, 3);
if ((int)$result['code'] !== 0) {
return [
'available' => false,
'file_count' => null,
'total_bytes' => null,
'total_size' => '-',
];
}
$sizes = array_values(array_filter(
array_map('trim', preg_split('/\R/', trim((string)$result['out'])) ?: []),
static fn($line): bool => $line !== ''
));
$total = array_sum(array_map('intval', $sizes));
return [
'available' => true,
'file_count' => count($sizes),
'total_bytes' => $total,
'total_size' => bytes_human($total),
];
}
function remote_log_status(): array
{
$journalDir = '/var/log/journal';
$journalDisk = command_first_line(['/usr/bin/journalctl', '--disk-usage', '--no-pager'], false, 3);
$journalBoots = command_first_line(['/usr/bin/bash', '-lc', 'journalctl --list-boots --no-pager 2>/dev/null | wc -l'], false, 3);
$rsyslogActive = command_first_line(['/usr/bin/systemctl', 'is-active', 'rsyslog'], false, 3) ?: 'unknown';
$rsyslogEnabled = command_first_line(['/usr/bin/systemctl', 'is-enabled', 'rsyslog'], false, 3) ?: 'unknown';
$pstoreActive = command_first_line(['/usr/bin/systemctl', 'is-active', 'control-pstore-collect.service'], false, 3) ?: 'inactive';
$listener = sh(['/usr/bin/ss', '-lnt', 'sport', '=', ':6514'], false, 3);
$listenerOk = (int)$listener['code'] === 0 && str_contains((string)$listener['out'], ':6514');
$remoteFiles = remote_log_file_status('/var/log/remote/synology');
$queue = remote_log_queue_status();
$savedCrashFiles = glob('/var/log/pstore/*') ?: [];
$pstoreFiles = glob('/sys/fs/pstore/*') ?: [];
return [
'raspberry_to_synology' => [
'target' => 'chaegeon.com:6514',
'transport' => 'TCP/TLS',
'rsyslog_active' => $rsyslogActive,
'rsyslog_enabled' => $rsyslogEnabled,
'queue' => $queue,
'ok' => $rsyslogActive === 'active',
],
'synology_to_raspberry' => [
'listen_port' => 6514,
'transport' => 'TCP/TLS',
'listener' => $listenerOk,
'logs' => $remoteFiles,
'ok' => $listenerOk && $remoteFiles['file_count'] > 0,
],
'journal' => [
'path' => $journalDir,
'persistent' => is_dir($journalDir),
'disk_usage' => $journalDisk ?: '-',
'boot_count' => is_numeric(trim($journalBoots)) ? (int)trim($journalBoots) : null,
'ok' => is_dir($journalDir),
],
'pstore' => [
'mounted' => is_dir('/sys/fs/pstore'),
'collector_active' => $pstoreActive,
'pending_files' => count($pstoreFiles),
'saved_files' => count($savedCrashFiles),
'saved_path' => '/var/log/pstore',
'ok' => is_dir('/sys/fs/pstore'),
],
];
}
function power_event_state_read(string $eventKey): array
{
$state = [
@@ -4123,6 +4258,7 @@ function collect_snapshot(bool $applyFan = true): array
'history' => $history,
'processes' => $processes,
'custom_services' => custom_systemd_services(),
'remote_logs' => remote_log_status(),
'touch_display' => touch_display_status(),
'fan_spike' => $fanSpike,
'fan_spike_history' => fan_spike_history((int)setting_value('display.fan_notice_history_limit')),
+138
View File
@@ -37,6 +37,7 @@
statusBatteryRemaining: $('#statusBatteryRemaining'),
batteryRuntimeTooltip: $('#batteryRuntimeTooltip'),
spikeLogList: $('#spikeLogList'),
remoteLogList: $('#remoteLogList'),
customServiceList: $('#customServiceList'),
noticeBaseline: $('#noticeBaseline'),
rebootBtn: $('#rebootBtn'),
@@ -179,6 +180,24 @@
noNoticeHistory: 'No system notice history.',
customServices: 'Custom Services',
noCustomServices: 'No custom services.',
remoteLogs: 'Remote Logs',
remoteLogsLoading: 'Remote log status loading...',
remotePiToSynology: 'Raspberry -> Synology',
remoteSynologyToPi: 'Synology -> Raspberry',
remotePersistentJournal: 'Persistent Journal',
remoteCrashLogs: 'Crash Logs',
remoteOk: 'OK',
remoteWarn: 'Check',
remoteTarget: 'Target',
remoteTransport: 'Transport',
remoteQueue: 'Queue',
remoteListener: 'Listener',
remoteLatest: 'Latest',
remoteFiles: 'Files',
remoteUsage: 'Usage',
remoteBoots: 'Boots',
remoteSaved: 'Saved',
remotePending: 'Pending',
serviceLogs: 'Logs',
serviceEnabled: 'enabled',
servicePid: 'pid',
@@ -353,6 +372,24 @@
noNoticeHistory: '시스템 알림 이력이 없습니다.',
customServices: '사용자 서비스',
noCustomServices: '사용자 생성 서비스가 없습니다.',
remoteLogs: '원격 로그',
remoteLogsLoading: '원격 로그 상태 확인 중...',
remotePiToSynology: '라즈베리파이 -> 시놀로지',
remoteSynologyToPi: '시놀로지 -> 라즈베리파이',
remotePersistentJournal: '영구 저널',
remoteCrashLogs: '커널 크래시',
remoteOk: '정상',
remoteWarn: '확인',
remoteTarget: '대상',
remoteTransport: '전송',
remoteQueue: '대기열',
remoteListener: '수신',
remoteLatest: '최근',
remoteFiles: '파일',
remoteUsage: '사용량',
remoteBoots: '부팅 이력',
remoteSaved: '저장',
remotePending: '대기',
serviceLogs: '로그',
serviceEnabled: '활성화',
servicePid: 'PID',
@@ -1957,6 +1994,106 @@
: `<div class="spike-log-empty">${escapeHtml(t('noNoticeHistory'))}</div>`;
}
function remoteState(ok, warn = false) {
if (ok) {
return {
className: 'active',
label: t('remoteOk'),
};
}
return {
className: warn ? 'warning' : 'failed',
label: t('remoteWarn'),
};
}
function remoteMeta(items) {
return items
.filter(item => item.value !== null && item.value !== undefined && item.value !== '')
.map(item => `${item.label}: ${item.value}`)
.join(' · ') || '-';
}
function remoteAge(row) {
if (!row || row.age_seconds === null || row.age_seconds === undefined) {
return '-';
}
const formatted = formatDhms(Number(row.age_seconds));
return formatted ? `${formatted} ${t('ago')}` : '-';
}
function renderRemoteLogs(data = {}) {
if (!els.remoteLogList) return;
const piToSynology = data.raspberry_to_synology || {};
const synologyToPi = data.synology_to_raspberry || {};
const journal = data.journal || {};
const pstore = data.pstore || {};
const newest = synologyToPi.logs?.newest || null;
const queue = piToSynology.queue || {};
const rows = [
{
title: t('remotePiToSynology'),
desc: remoteMeta([
{ label: t('remoteTarget'), value: piToSynology.target || '-' },
{ label: t('remoteTransport'), value: piToSynology.transport || '-' },
]),
meta: remoteMeta([
{ label: 'rsyslog', value: `${piToSynology.rsyslog_active || '-'} / ${piToSynology.rsyslog_enabled || '-'}` },
{ label: t('remoteQueue'), value: queue.available === false ? '-' : `${Number(queue.file_count || 0).toLocaleString()} / ${queue.total_size || '-'}` },
]),
state: remoteState(Boolean(piToSynology.ok), true),
},
{
title: t('remoteSynologyToPi'),
desc: remoteMeta([
{ label: t('remoteListener'), value: synologyToPi.listener ? `:${synologyToPi.listen_port || 6514}` : '-' },
{ label: t('remoteTransport'), value: synologyToPi.transport || '-' },
]),
meta: remoteMeta([
{ label: t('remoteLatest'), value: newest ? `${newest.name || '-'} / ${remoteAge(newest)}` : '-' },
{ label: t('remoteFiles'), value: `${Number(synologyToPi.logs?.file_count || 0).toLocaleString()} / ${synologyToPi.logs?.total_size || '-'}` },
]),
state: remoteState(Boolean(synologyToPi.ok), true),
},
{
title: t('remotePersistentJournal'),
desc: journal.path || '-',
meta: remoteMeta([
{ label: t('remoteUsage'), value: journal.disk_usage || '-' },
{ label: t('remoteBoots'), value: journal.boot_count === null || journal.boot_count === undefined ? '-' : Number(journal.boot_count).toLocaleString() },
]),
state: remoteState(Boolean(journal.ok), true),
},
{
title: t('remoteCrashLogs'),
desc: pstore.saved_path || '-',
meta: remoteMeta([
{ label: 'pstore', value: pstore.mounted ? 'mounted' : '-' },
{ label: 'collector', value: pstore.collector_active || '-' },
{ label: t('remoteSaved'), value: Number(pstore.saved_files || 0).toLocaleString() },
{ label: t('remotePending'), value: Number(pstore.pending_files || 0).toLocaleString() },
]),
state: remoteState(Boolean(pstore.ok), true),
},
];
els.remoteLogList.innerHTML = rows.map(row => `
<div class="service-item">
<div class="service-head">
<div class="service-title">
<div class="service-name">${escapeHtml(row.title)}</div>
<div class="service-desc">${escapeHtml(row.desc)}</div>
</div>
<div class="service-state ${row.state.className}">${escapeHtml(row.state.label)}</div>
</div>
<div class="service-meta">${escapeHtml(row.meta)}</div>
</div>
`).join('');
}
function renderCustomServices(rows = []) {
if (!els.customServiceList) return;
if (Date.now() < state.customServiceScrollHoldUntil) return;
@@ -2011,6 +2148,7 @@
? `${baselineTemp.toFixed(1)}°C / ${Math.round(baselineRpm).toLocaleString()} RPM · ${stateText}`
: stateText;
renderSpikeHistory(data.fan_spike_history || []);
renderRemoteLogs(data.remote_logs || {});
renderCustomServices(data.custom_services || []);
setText(els.noticeBaseline, baselineText);
+9 -2
View File
@@ -138,8 +138,8 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
.wifi-signal{font-weight:700;font-variant-numeric:tabular-nums;cursor:help;text-decoration:underline;text-decoration-style:dotted;text-decoration-thickness:1px;text-underline-offset:3px}.wifi-signal.best{color:#16a34a}.wifi-signal.excellent{color:#22c55e}.wifi-signal.good{color:#38bdf8}.wifi-signal.fine{color:#0ea5e9}.wifi-signal.normal{color:#f59e0b}.wifi-signal.weak{color:#fb7185}.wifi-signal.bad{color:#ef4444}.wifi-signal.unknown{color:var(--sub);text-decoration:none;cursor:default}
.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-secondary{margin-top:14px}
.details-panel{border:1px solid rgba(84,101,128,.52);border-radius:16px;background:rgba(128,145,170,.06);padding:0;margin-top:14px;overflow:hidden}.details-panel>summary{cursor:pointer;list-style:none;display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:48px;padding:0 14px;color:var(--text);font-weight:500}.details-panel>summary::-webkit-details-marker{display:none}.details-panel>summary::after{content:"+";display:grid;place-items:center;width:24px;height:24px;border:1px solid var(--line);border-radius:999px;color:var(--sub);font-weight:700}.details-panel[open]>summary::after{content:"-"}.details-body{padding:0 14px 14px}.section-note{margin:0;color:var(--sub);font-size:12px}
.resource-card{margin-top:18px}.resource-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px}.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-list.compact{max-height:180px}.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}.service-list{display:grid;gap:10px;max-height:520px;overflow:auto;padding-right:4px}.service-item{border:1px solid var(--line);border-radius:12px;background:var(--card2);padding:12px;display:grid;gap:9px}.service-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}.service-title{display:grid;gap:3px;min-width:0}.service-name{font-size:14px;color:var(--text);font-weight:600;word-break:break-word}.service-desc,.service-meta{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums;word-break:break-word}.service-state{border:1px solid var(--line);border-radius:999px;padding:4px 9px;font-size:12px;color:var(--sub);white-space:nowrap}.service-state.active{color:#bbf7d0;border-color:rgba(34,197,94,.35);background:rgba(22,163,74,.14)}.service-state.failed{color:#fecaca;border-color:rgba(239,68,68,.4);background:rgba(185,28,28,.16)}.service-log{margin:0;max-height:160px;overflow:auto;border:1px solid var(--line);border-radius:10px;background:var(--code-bg);color:var(--mono-text);padding:10px;font:11px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word}.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}
html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}html[data-theme="light"] .service-item{background:#e8f0f8;border-color:#b7c8dd}html[data-theme="light"] .service-name{color:#0f1f35}html[data-theme="light"] .service-desc,html[data-theme="light"] .service-meta{color:#42526a}html[data-theme="light"] .service-state{background:#f8fbff;border-color:#aebed2;color:#304057}html[data-theme="light"] .service-state.active{background:#d8f3e3;border-color:#7fc99c;color:#146c39}html[data-theme="light"] .service-state.failed{background:#ffe2e2;border-color:#e38b8b;color:#9f1d1d}html[data-theme="light"] .service-log,html[data-theme="light"] .dmesg-log{background:#f7fafc;border-color:#b9c8da;color:#0f2742}
.resource-card{margin-top:18px}.resource-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px}.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-list.compact{max-height:180px}.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}.service-list{display:grid;gap:10px;max-height:520px;overflow:auto;padding-right:4px}.service-list.remote-log-list{grid-template-columns:repeat(auto-fit,minmax(230px,1fr));max-height:none}.service-item{border:1px solid var(--line);border-radius:12px;background:var(--card2);padding:12px;display:grid;gap:9px}.service-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}.service-title{display:grid;gap:3px;min-width:0}.service-name{font-size:14px;color:var(--text);font-weight:600;word-break:break-word}.service-desc,.service-meta{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums;word-break:break-word}.service-state{border:1px solid var(--line);border-radius:999px;padding:4px 9px;font-size:12px;color:var(--sub);white-space:nowrap}.service-state.active{color:#bbf7d0;border-color:rgba(34,197,94,.35);background:rgba(22,163,74,.14)}.service-state.warning{color:#fde68a;border-color:rgba(245,158,11,.42);background:rgba(180,83,9,.16)}.service-state.failed{color:#fecaca;border-color:rgba(239,68,68,.4);background:rgba(185,28,28,.16)}.service-log{margin:0;max-height:160px;overflow:auto;border:1px solid var(--line);border-radius:10px;background:var(--code-bg);color:var(--mono-text);padding:10px;font:11px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word}.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}
html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}html[data-theme="light"] .service-item{background:#e8f0f8;border-color:#b7c8dd}html[data-theme="light"] .service-name{color:#0f1f35}html[data-theme="light"] .service-desc,html[data-theme="light"] .service-meta{color:#42526a}html[data-theme="light"] .service-state{background:#f8fbff;border-color:#aebed2;color:#304057}html[data-theme="light"] .service-state.active{background:#d8f3e3;border-color:#7fc99c;color:#146c39}html[data-theme="light"] .service-state.warning{background:#fff2c7;border-color:#dca642;color:#8a4d08}html[data-theme="light"] .service-state.failed{background:#ffe2e2;border-color:#e38b8b;color:#9f1d1d}html[data-theme="light"] .service-log,html[data-theme="light"] .dmesg-log{background:#f7fafc;border-color:#b9c8da;color:#0f2742}
.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)}
.status-value.interactive{cursor:help;text-decoration:underline;text-decoration-style:dotted;text-decoration-thickness:1px;text-underline-offset:4px}.runtime-tooltip{position:fixed;left:0;top:0;z-index:110;display:none;width:min(520px,calc(100vw - 28px));max-height:calc(100vh - 28px);overflow:hidden;padding:14px 16px;border:1px solid rgba(84,101,128,.78);border-radius:16px;background:var(--card);color:var(--text);box-shadow:0 18px 48px rgba(0,0,0,.38);font-size:13px;line-height:1.55;white-space:pre-line;word-break:keep-all;overflow-wrap:anywhere;pointer-events:none}.runtime-tooltip.signal-tooltip{width:auto;min-width:118px;max-width:220px;padding:9px 12px;border-radius:12px;font-size:12px;line-height:1.45;box-shadow:0 12px 30px rgba(0,0,0,.28)}.runtime-tooltip[data-open="1"]{display:block}.runtime-tooltip::before{content:"";position:absolute;left:18px;top:-7px;width:12px;height:12px;border-left:1px solid rgba(84,101,128,.78);border-top:1px solid rgba(84,101,128,.78);background:var(--card);transform:rotate(45deg)}html[data-theme="light"] .runtime-tooltip{background:#f8fbff;border-color:#aebed2;color:#172033;box-shadow:0 18px 44px rgba(28,43,64,.18)}html[data-theme="light"] .runtime-tooltip.signal-tooltip{box-shadow:0 12px 28px rgba(28,43,64,.16)}html[data-theme="light"] .runtime-tooltip::before{background:#f8fbff;border-color:#aebed2}
@@ -316,6 +316,13 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
<details class="details-panel" id="diagnosticDetails">
<summary data-i18n="diagnostics">Diagnostics</summary>
<div class="details-body">
<div class="spike-log-box">
<h3 data-i18n="remoteLogs">Remote Logs</h3>
<div id="remoteLogList" class="service-list remote-log-list">
<div class="spike-log-empty" data-i18n="remoteLogsLoading">Remote log status loading...</div>
</div>
</div>
<div class="spike-log-box">
<h3 data-i18n="customServices">Custom Services</h3>
<div id="customServiceList" class="service-list">