Show custom systemd services
This commit is contained in:
+152
@@ -1871,6 +1871,157 @@ function action_rows(int $limit = 80): array
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function custom_systemd_service_names(): array
|
||||
{
|
||||
$paths = glob('/etc/systemd/system/*.service') ?: [];
|
||||
$names = [];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
if (is_link($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = basename($path);
|
||||
if (preg_match('/^[A-Za-z0-9_.@:-]+\.service$/', $name)) {
|
||||
$names[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
sort($names, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
return $names;
|
||||
}
|
||||
|
||||
function parse_systemctl_show_records(string $out): array
|
||||
{
|
||||
$records = [];
|
||||
$current = [];
|
||||
|
||||
foreach (preg_split('/\R/', trim($out)) as $line) {
|
||||
if ($line === '') {
|
||||
if (!empty($current['Id'])) {
|
||||
$records[$current['Id']] = $current;
|
||||
}
|
||||
$current = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode('=', $line, 2);
|
||||
if (count($parts) === 2) {
|
||||
$current[$parts[0]] = $parts[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($current['Id'])) {
|
||||
$records[$current['Id']] = $current;
|
||||
}
|
||||
|
||||
return $records;
|
||||
}
|
||||
|
||||
function systemd_service_logs(string $unit, int $limit = 12): array
|
||||
{
|
||||
$limit = max(1, min(50, $limit));
|
||||
$cmd = [
|
||||
'/usr/bin/journalctl',
|
||||
'-u',
|
||||
$unit,
|
||||
'-n',
|
||||
(string)$limit,
|
||||
'--no-pager',
|
||||
'--output=short-iso',
|
||||
];
|
||||
|
||||
$result = sh($cmd, false, 4);
|
||||
if ((int)$result['code'] !== 0) {
|
||||
$result = sh($cmd, true, 4);
|
||||
}
|
||||
|
||||
$lines = array_values(array_filter(
|
||||
preg_split('/\R/', trim((string)$result['out'])),
|
||||
static fn($line) => trim((string)$line) !== ''
|
||||
));
|
||||
|
||||
return [
|
||||
'ok' => (int)$result['code'] === 0,
|
||||
'lines' => $lines,
|
||||
'error' => (int)$result['code'] === 0 ? '' : (string)$result['out'],
|
||||
];
|
||||
}
|
||||
|
||||
function custom_systemd_services(): array
|
||||
{
|
||||
$cachePath = '/tmp/control-custom-services.json';
|
||||
if (is_file($cachePath) && filemtime($cachePath) >= time() - 5) {
|
||||
$cached = json_decode((string)@file_get_contents($cachePath), true);
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$names = custom_systemd_service_names();
|
||||
if (!$names) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$show = sh(array_merge([
|
||||
'/usr/bin/systemctl',
|
||||
'show',
|
||||
], $names, [
|
||||
'--no-pager',
|
||||
'-p',
|
||||
'Id',
|
||||
'-p',
|
||||
'LoadState',
|
||||
'-p',
|
||||
'ActiveState',
|
||||
'-p',
|
||||
'SubState',
|
||||
'-p',
|
||||
'UnitFileState',
|
||||
'-p',
|
||||
'Description',
|
||||
'-p',
|
||||
'FragmentPath',
|
||||
'-p',
|
||||
'ActiveEnterTimestamp',
|
||||
'-p',
|
||||
'InactiveEnterTimestamp',
|
||||
'-p',
|
||||
'ExecMainPID',
|
||||
'-p',
|
||||
'NRestarts',
|
||||
]), false, 6);
|
||||
|
||||
$records = parse_systemctl_show_records((string)$show['out']);
|
||||
$rows = [];
|
||||
|
||||
foreach ($names as $name) {
|
||||
$record = $records[$name] ?? [];
|
||||
$logs = systemd_service_logs($name, 12);
|
||||
|
||||
$rows[] = [
|
||||
'name' => $name,
|
||||
'description' => (string)($record['Description'] ?? ''),
|
||||
'load' => (string)($record['LoadState'] ?? ''),
|
||||
'active' => (string)($record['ActiveState'] ?? 'unknown'),
|
||||
'sub' => (string)($record['SubState'] ?? ''),
|
||||
'enabled' => (string)($record['UnitFileState'] ?? ''),
|
||||
'pid' => (int)($record['ExecMainPID'] ?? 0),
|
||||
'restarts' => (int)($record['NRestarts'] ?? 0),
|
||||
'active_enter' => (string)($record['ActiveEnterTimestamp'] ?? ''),
|
||||
'inactive_enter' => (string)($record['InactiveEnterTimestamp'] ?? ''),
|
||||
'fragment' => (string)($record['FragmentPath'] ?? ''),
|
||||
'logs' => $logs['lines'],
|
||||
'logs_ok' => $logs['ok'],
|
||||
'logs_error' => $logs['ok'] ? '' : $logs['error'],
|
||||
];
|
||||
}
|
||||
|
||||
@file_put_contents($cachePath, json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
function collect_snapshot(bool $applyFan = true): array
|
||||
{
|
||||
if ($applyFan) {
|
||||
@@ -2008,6 +2159,7 @@ function collect_snapshot(bool $applyFan = true): array
|
||||
'wifi' => wifi_data(),
|
||||
'history' => $history,
|
||||
'processes' => $processes,
|
||||
'custom_services' => custom_systemd_services(),
|
||||
'fan_spike' => $fanSpike,
|
||||
'fan_spike_history' => fan_spike_history(100),
|
||||
'ha_notify_history' => ha_notify_log_rows(20),
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
statusBatteryRemaining: $('#statusBatteryRemaining'),
|
||||
spikeLogList: $('#spikeLogList'),
|
||||
haNotifyList: $('#haNotifyList'),
|
||||
customServiceList: $('#customServiceList'),
|
||||
noticeBaseline: $('#noticeBaseline'),
|
||||
rebootBtn: $('#rebootBtn'),
|
||||
translateBtn: $('#translateBtn'),
|
||||
@@ -140,6 +141,12 @@
|
||||
noNoticeHistory: 'No system notice history.',
|
||||
haNotifyHistory: 'HA Notify History',
|
||||
noHaNotifyHistory: 'No HA notify history.',
|
||||
customServices: 'Custom Services',
|
||||
noCustomServices: 'No custom services.',
|
||||
serviceLogs: 'Logs',
|
||||
serviceEnabled: 'enabled',
|
||||
servicePid: 'pid',
|
||||
serviceRestarts: 'restarts',
|
||||
notifySuccess: 'success',
|
||||
notifyFailed: 'failed',
|
||||
show: 'Show',
|
||||
@@ -262,6 +269,12 @@
|
||||
noNoticeHistory: '시스템 알림 이력이 없습니다.',
|
||||
haNotifyHistory: 'HA 알림 이력',
|
||||
noHaNotifyHistory: 'HA 알림 이력이 없습니다.',
|
||||
customServices: '사용자 서비스',
|
||||
noCustomServices: '사용자 생성 서비스가 없습니다.',
|
||||
serviceLogs: '로그',
|
||||
serviceEnabled: '활성화',
|
||||
servicePid: 'PID',
|
||||
serviceRestarts: '재시작',
|
||||
notifySuccess: '성공',
|
||||
notifyFailed: '실패',
|
||||
show: '보기',
|
||||
@@ -1032,6 +1045,42 @@
|
||||
: `<div class="spike-log-empty">${escapeHtml(t('noHaNotifyHistory'))}</div>`;
|
||||
}
|
||||
|
||||
function renderCustomServices(rows = []) {
|
||||
if (!els.customServiceList) return;
|
||||
|
||||
els.customServiceList.innerHTML = rows.length
|
||||
? rows.map(row => {
|
||||
const active = String(row.active || 'unknown');
|
||||
const sub = String(row.sub || '').trim();
|
||||
const stateClass = active === 'active' ? 'active' : (active === 'failed' ? 'failed' : '');
|
||||
const stateLabel = sub ? `${active} / ${sub}` : active;
|
||||
const meta = [
|
||||
row.enabled ? `${t('serviceEnabled')}: ${row.enabled}` : '',
|
||||
Number(row.pid || 0) > 0 ? `${t('servicePid')}: ${Number(row.pid).toLocaleString()}` : '',
|
||||
Number(row.restarts || 0) > 0 ? `${t('serviceRestarts')}: ${Number(row.restarts).toLocaleString()}` : '',
|
||||
row.active_enter ? row.active_enter : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
const logLines = Array.isArray(row.logs) && row.logs.length
|
||||
? row.logs.join('\n')
|
||||
: (row.logs_ok === false ? row.logs_error : `${t('serviceLogs')}: -`);
|
||||
|
||||
return `
|
||||
<div class="service-item">
|
||||
<div class="service-head">
|
||||
<div class="service-title">
|
||||
<div class="service-name">${escapeHtml(row.name || '-')}</div>
|
||||
<div class="service-desc">${escapeHtml(row.description || row.fragment || '-')}</div>
|
||||
</div>
|
||||
<div class="service-state ${stateClass}">${escapeHtml(stateLabel)}</div>
|
||||
</div>
|
||||
<div class="service-meta">${escapeHtml(meta || row.fragment || '-')}</div>
|
||||
<pre class="service-log">${escapeHtml(logLines)}</pre>
|
||||
</div>
|
||||
`;
|
||||
}).join('')
|
||||
: `<div class="spike-log-empty">${escapeHtml(t('noCustomServices'))}</div>`;
|
||||
}
|
||||
|
||||
function renderFanCause(data) {
|
||||
const processes = data.processes || {};
|
||||
const baseline = data.fan_spike || {};
|
||||
@@ -1043,6 +1092,7 @@
|
||||
: stateText;
|
||||
renderSpikeHistory(data.fan_spike_history || []);
|
||||
renderHaNotifyHistory(data.ha_notify_history || []);
|
||||
renderCustomServices(data.custom_services || []);
|
||||
setText(els.noticeBaseline, baselineText);
|
||||
|
||||
renderProcessRows(els.processCpuTable, processes.cpu || [], 'cpu');
|
||||
|
||||
+9
-2
@@ -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}
|
||||
.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}
|
||||
.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-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}.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}
|
||||
.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)}
|
||||
.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 class="spike-log-box">
|
||||
<h3 data-i18n="customServices">Custom Services</h3>
|
||||
<div id="customServiceList" class="service-list">
|
||||
<div class="spike-log-empty" data-i18n="noCustomServices">No custom services.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spike-log-box">
|
||||
<h3 data-i18n="haNotifyHistory">HA Notify History</h3>
|
||||
<div id="haNotifyList" class="spike-log-list">
|
||||
@@ -300,7 +307,7 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/app.js?v=20260618_throttling2"></script>
|
||||
<script src="/assets/app.js?v=20260621_services1"></script>
|
||||
<script src="/assets/wakelock.js?v=20260607_prefs1"></script>
|
||||
<?php endif; ?>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user