Добавить управление рефлектором и дашборд статистики
Этот коммит содержится в:
@@ -13,7 +13,9 @@
|
||||
- установка и управление `xlxd`;
|
||||
- главный пользовательский дашборд `/`;
|
||||
- админка настроек `xlxd` `/admin`;
|
||||
- управление `xlxd` из админки: status/start/stop/restart;
|
||||
- графический редактор `/xlxd/xlxd.blacklist`, `xlxd.whitelist`, `xlxd.interlink`, `xlxd.terminal`;
|
||||
- статистика последних событий и вызовов на главной;
|
||||
- экспорт активных пользователей для будущего access gateway.
|
||||
|
||||
## Компоненты
|
||||
|
||||
@@ -50,3 +50,16 @@ sudo systemctl restart xlxd
|
||||
```
|
||||
|
||||
Установщик выдает группе `www-data` права записи на эти файлы, чтобы сохранение работало из веб-интерфейса.
|
||||
|
||||
## Управление сервисом
|
||||
|
||||
В админке есть блок `Управление reflector`:
|
||||
|
||||
- просмотр статуса `xlxd`;
|
||||
- `start`;
|
||||
- `stop`;
|
||||
- `restart`;
|
||||
- последние события из `/var/log/xlxd.log`;
|
||||
- счетчики активных пользователей и оплат.
|
||||
|
||||
Для управления сервисом установщик создает `/etc/sudoers.d/xlx-panel` и разрешает пользователю `www-data` выполнять только команды `systemctl start/stop/restart/status xlxd`.
|
||||
|
||||
@@ -5,6 +5,9 @@ const adminResult = document.querySelector('#adminResult');
|
||||
const loadButton = document.querySelector('#loadSettings');
|
||||
const saveButton = document.querySelector('#saveSettings');
|
||||
const applyButton = document.querySelector('#applySettings');
|
||||
const loadRuntimeButton = document.querySelector('#loadRuntime');
|
||||
const runtimeStats = document.querySelector('#runtimeStats');
|
||||
const runtimeResult = document.querySelector('#runtimeResult');
|
||||
const loadConfigFilesButton = document.querySelector('#loadConfigFiles');
|
||||
const saveConfigFileButton = document.querySelector('#saveConfigFile');
|
||||
const reloadConfigFileButton = document.querySelector('#reloadConfigFile');
|
||||
@@ -37,7 +40,7 @@ function fillForm(settings) {
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
setResult('Загружаем...');
|
||||
setResult('Загружаем настройки...');
|
||||
const response = await fetch('/api/admin/xlxd/settings', {
|
||||
headers: { 'X-Admin-Token': token() },
|
||||
});
|
||||
@@ -53,7 +56,7 @@ async function loadSettings() {
|
||||
}
|
||||
|
||||
async function saveSettings(apply) {
|
||||
setResult(apply ? 'Сохраняем и применяем...' : 'Сохраняем...');
|
||||
setResult(apply ? 'Сохраняем и применяем env...' : 'Сохраняем...');
|
||||
const response = await fetch('/api/admin/xlxd/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -72,9 +75,64 @@ async function saveSettings(apply) {
|
||||
setResult(payload.data.message);
|
||||
}
|
||||
|
||||
loadButton?.addEventListener('click', loadSettings);
|
||||
saveButton?.addEventListener('click', () => saveSettings(false));
|
||||
applyButton?.addEventListener('click', () => saveSettings(true));
|
||||
function renderRuntime(data) {
|
||||
const service = data.service || {};
|
||||
const counters = data.counters || {};
|
||||
runtimeStats.innerHTML = `
|
||||
<div class="stat-card"><span>Сервис</span><strong>${service.active || 'unknown'}</strong></div>
|
||||
<div class="stat-card"><span>Enabled</span><strong>${service.enabled || 'unknown'}</strong></div>
|
||||
<div class="stat-card"><span>Активные</span><strong>${counters.users_active ?? 0}</strong></div>
|
||||
<div class="stat-card"><span>Оплачено</span><strong>${counters.payments_paid ?? 0}</strong></div>
|
||||
`;
|
||||
|
||||
const events = data.latest_events || [];
|
||||
runtimeResult.textContent = [
|
||||
`service: ${service.service_name || 'xlxd'}`,
|
||||
`active: ${service.active || 'unknown'}`,
|
||||
`enabled: ${service.enabled || 'unknown'}`,
|
||||
`log: ${service.log_path || ''}`,
|
||||
'',
|
||||
'Последние события:',
|
||||
...events.map((event) => event.message),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function loadRuntime() {
|
||||
runtimeResult.textContent = 'Загружаем runtime-статус...';
|
||||
const response = await fetch('/api/admin/xlxd/runtime', {
|
||||
headers: { 'X-Admin-Token': token() },
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!payload.ok) {
|
||||
runtimeResult.textContent = payload.error || 'Ошибка загрузки статуса';
|
||||
return;
|
||||
}
|
||||
renderRuntime(payload.data);
|
||||
}
|
||||
|
||||
async function runRuntimeAction(action) {
|
||||
runtimeResult.textContent = `Выполняем ${action}...`;
|
||||
const response = await fetch('/api/admin/xlxd/runtime', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-Token': token(),
|
||||
},
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!payload.ok) {
|
||||
runtimeResult.textContent = payload.error || `Ошибка ${action}`;
|
||||
return;
|
||||
}
|
||||
|
||||
await loadRuntime();
|
||||
runtimeResult.textContent = [
|
||||
`action: ${payload.data.action}`,
|
||||
`exit_code: ${payload.data.exit_code}`,
|
||||
payload.data.output || 'ok',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function selectedFile() {
|
||||
return configFiles.find((file) => file.key === selectedFileKey) || null;
|
||||
@@ -165,6 +223,13 @@ async function saveConfigFile() {
|
||||
setFileResult(`${payload.data.filename} сохранен.`);
|
||||
}
|
||||
|
||||
loadButton?.addEventListener('click', loadSettings);
|
||||
saveButton?.addEventListener('click', () => saveSettings(false));
|
||||
applyButton?.addEventListener('click', () => saveSettings(true));
|
||||
loadRuntimeButton?.addEventListener('click', loadRuntime);
|
||||
document.querySelectorAll('[data-runtime-action]').forEach((button) => {
|
||||
button.addEventListener('click', () => runRuntimeAction(button.dataset.runtimeAction));
|
||||
});
|
||||
loadConfigFilesButton?.addEventListener('click', loadConfigFiles);
|
||||
saveConfigFileButton?.addEventListener('click', saveConfigFile);
|
||||
reloadConfigFileButton?.addEventListener('click', () => {
|
||||
|
||||
@@ -1,5 +1,49 @@
|
||||
const form = document.querySelector('#registerForm');
|
||||
const result = document.querySelector('#registerResult');
|
||||
const statsGrid = document.querySelector('#statsGrid');
|
||||
const eventList = document.querySelector('#eventList');
|
||||
const refreshStats = document.querySelector('#refreshStats');
|
||||
|
||||
function renderStats(data) {
|
||||
const counters = data.counters || {};
|
||||
const service = data.service || {};
|
||||
statsGrid.innerHTML = `
|
||||
<div class="stat-card"><span>Сервис</span><strong>${service.active || 'unknown'}</strong></div>
|
||||
<div class="stat-card"><span>Активные</span><strong>${counters.users_active ?? 0}</strong></div>
|
||||
<div class="stat-card"><span>Пользователи</span><strong>${counters.users_total ?? 0}</strong></div>
|
||||
<div class="stat-card"><span>Ожидают оплату</span><strong>${counters.payments_pending ?? 0}</strong></div>
|
||||
`;
|
||||
|
||||
const events = data.latest_events || [];
|
||||
if (events.length === 0) {
|
||||
eventList.innerHTML = '<div class="event-row muted-text">Пока нет событий в логе или лог недоступен.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
eventList.innerHTML = events.map((event) => `
|
||||
<div class="event-row">
|
||||
<span>${event.time || 'без времени'}</span>
|
||||
<strong>${event.callsign || 'XLX'}</strong>
|
||||
<p>${event.message}</p>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/stats');
|
||||
const payload = await response.json();
|
||||
if (!payload.ok) {
|
||||
throw new Error(payload.error || 'Ошибка загрузки статистики');
|
||||
}
|
||||
renderStats(payload.data);
|
||||
} catch (error) {
|
||||
eventList.innerHTML = `<div class="event-row muted-text">${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
refreshStats?.addEventListener('click', loadStats);
|
||||
loadStats();
|
||||
|
||||
form?.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -150,6 +150,69 @@ label {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 18px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.stat-card span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-size: 34px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dashboard-panel,
|
||||
.service-control {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.event-row {
|
||||
display: grid;
|
||||
grid-template-columns: 150px 110px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #06070b;
|
||||
}
|
||||
|
||||
.event-row span {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-row strong {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.event-row p {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tile {
|
||||
padding: 22px;
|
||||
}
|
||||
@@ -310,6 +373,8 @@ textarea:focus {
|
||||
@media (max-width: 880px) {
|
||||
.hero,
|
||||
.grid,
|
||||
.stats-grid,
|
||||
.event-row,
|
||||
.panel.split,
|
||||
.settings-form,
|
||||
.config-editor-head {
|
||||
|
||||
@@ -8,6 +8,7 @@ use Xlx\Domain\IdAllocator;
|
||||
use Xlx\Domain\PaymentService;
|
||||
use Xlx\Domain\RegistrationService;
|
||||
use Xlx\Domain\XlxdConfigFileService;
|
||||
use Xlx\Domain\XlxdRuntimeService;
|
||||
use Xlx\Domain\XlxdSettingsService;
|
||||
use Xlx\Domain\YooKassaClient;
|
||||
use Xlx\Support\Database;
|
||||
@@ -36,6 +37,10 @@ try {
|
||||
Response::json(['ok' => true, 'service' => 'xlx-panel']);
|
||||
}
|
||||
|
||||
if ($method === 'GET' && $path === '/api/dashboard/stats') {
|
||||
Response::json(['ok' => true, 'data' => (new XlxdRuntimeService($config))->dashboard($pdo)]);
|
||||
}
|
||||
|
||||
if ($method === 'GET' && $path === '/api/callsigns/check') {
|
||||
$paymentService = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
|
||||
$service = new RegistrationService($config, $paymentService);
|
||||
@@ -130,6 +135,17 @@ try {
|
||||
Response::json(['ok' => true, 'data' => $service->save((string) ($input['file'] ?? ''), (string) ($input['content'] ?? ''))]);
|
||||
}
|
||||
|
||||
if ($method === 'GET' && $path === '/api/admin/xlxd/runtime') {
|
||||
Security::requireAdminToken($config);
|
||||
Response::json(['ok' => true, 'data' => (new XlxdRuntimeService($config))->dashboard($pdo)]);
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $path === '/api/admin/xlxd/runtime') {
|
||||
Security::requireAdminToken($config);
|
||||
$input = Input::json();
|
||||
Response::json(['ok' => true, 'data' => (new XlxdRuntimeService($config))->control((string) ($input['action'] ?? ''))]);
|
||||
}
|
||||
|
||||
Response::error('Route not found.', 404);
|
||||
} catch (Throwable $throwable) {
|
||||
Response::error($throwable->getMessage(), 400);
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
<section class="hero compact">
|
||||
<div>
|
||||
<p class="eyebrow">Админка</p>
|
||||
<h1>Настройка XLX без ручного редактирования конфигов</h1>
|
||||
<p class="lead">Меняй имя reflector, домен, порты, модули и пути. Настройки сохраняются в БД и могут сразу применяться в `/etc/xlx/xlxd.env`.</p>
|
||||
<h1>Полная настройка XLX reflector</h1>
|
||||
<p class="lead">Настраивай параметры `xlxd`, управляй сервисом, редактируй blacklist, whitelist, interlink и terminal без ручного SSH-редактирования.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -23,16 +23,40 @@
|
||||
<div class="actions">
|
||||
<button class="button" id="loadSettings">Загрузить настройки</button>
|
||||
<button class="button ghost" id="saveSettings">Сохранить</button>
|
||||
<button class="button ghost" id="applySettings">Сохранить и применить</button>
|
||||
<button class="button ghost" id="applySettings">Сохранить и применить env</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel service-control">
|
||||
<div class="config-editor-head">
|
||||
<div>
|
||||
<p class="eyebrow">Управление reflector</p>
|
||||
<h2>Статус и перезапуск `xlxd`</h2>
|
||||
<p class="muted-text">После изменения env или файлов XLX можно перезапустить сервис прямо отсюда.</p>
|
||||
</div>
|
||||
<button class="button ghost" id="loadRuntime" type="button">Обновить статус</button>
|
||||
</div>
|
||||
<div class="stats-grid" id="runtimeStats">
|
||||
<div class="stat-card"><span>Сервис</span><strong>...</strong></div>
|
||||
<div class="stat-card"><span>Enabled</span><strong>...</strong></div>
|
||||
<div class="stat-card"><span>Активные</span><strong>...</strong></div>
|
||||
<div class="stat-card"><span>Оплачено</span><strong>...</strong></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="button" data-runtime-action="start" type="button">Start</button>
|
||||
<button class="button ghost" data-runtime-action="restart" type="button">Restart</button>
|
||||
<button class="button ghost" data-runtime-action="stop" type="button">Stop</button>
|
||||
<button class="button ghost" data-runtime-action="status" type="button">Status</button>
|
||||
</div>
|
||||
<pre class="result" id="runtimeResult"></pre>
|
||||
</section>
|
||||
|
||||
<section class="panel split">
|
||||
<form class="form settings-form" id="settingsForm">
|
||||
<label>Reflector
|
||||
<input name="reflector_name" placeholder="XLX138">
|
||||
</label>
|
||||
<label>Домен
|
||||
<label>Домен или IP
|
||||
<input name="server_host" placeholder="xlx.example.com">
|
||||
</label>
|
||||
<label>Позывной sysop
|
||||
@@ -93,10 +117,7 @@
|
||||
</div>
|
||||
|
||||
<div class="file-tabs" id="fileTabs"></div>
|
||||
|
||||
<div class="file-meta" id="fileMeta">
|
||||
<span>Файл не выбран</span>
|
||||
</div>
|
||||
<div class="file-meta" id="fileMeta"><span>Файл не выбран</span></div>
|
||||
|
||||
<label class="editor-label">Содержимое файла
|
||||
<textarea id="fileEditor" spellcheck="false" placeholder="Загрузите файл для редактирования"></textarea>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">XLX / DMR / Pi-Star</p>
|
||||
<h1>Личный доступ к DMR-серверу без ручной настройки</h1>
|
||||
<h1>DMR-рефлектор с личным доступом</h1>
|
||||
<p class="lead">Регистрация по позывному без дублей, оплата через YooKassa или чек перевода, автоматический DMR ID и пароль для Pi-Star.</p>
|
||||
<div class="actions">
|
||||
<a class="button" href="#register">Подключиться</a>
|
||||
@@ -34,6 +34,26 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel dashboard-panel">
|
||||
<div class="config-editor-head">
|
||||
<div>
|
||||
<p class="eyebrow">Статистика</p>
|
||||
<h2>Последние вызовы и события</h2>
|
||||
<p class="muted-text">Сводка строится по локальной базе и последним строкам `/var/log/xlxd.log`.</p>
|
||||
</div>
|
||||
<button class="button ghost" id="refreshStats" type="button">Обновить</button>
|
||||
</div>
|
||||
<div class="stats-grid" id="statsGrid">
|
||||
<div class="stat-card"><span>Сервис</span><strong>...</strong></div>
|
||||
<div class="stat-card"><span>Активные</span><strong>...</strong></div>
|
||||
<div class="stat-card"><span>Пользователи</span><strong>...</strong></div>
|
||||
<div class="stat-card"><span>Ожидают оплату</span><strong>...</strong></div>
|
||||
</div>
|
||||
<div class="event-list" id="eventList">
|
||||
<div class="event-row muted-text">Загружаем последние события...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="tile">
|
||||
<span class="num">01</span>
|
||||
@@ -56,7 +76,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">Регистрация</p>
|
||||
<h2>Создать заявку</h2>
|
||||
<p>После отправки появится ссылка YooKassa и инструкция для оплаты переводом.</p>
|
||||
<p class="muted-text">После отправки появится ссылка YooKassa и инструкция для оплаты переводом.</p>
|
||||
</div>
|
||||
<form class="form" id="registerForm">
|
||||
<label>Позывной
|
||||
|
||||
@@ -97,6 +97,7 @@ REFLECTOR_NAME="${XLX_REFLECTOR_NAME:-XLX000}"
|
||||
SYSOP_CALLSIGN="${XLX_SYSOP_CALLSIGN:-N0CALL}"
|
||||
SYSOP_EMAIL="${XLX_SYSOP_EMAIL:-admin@${PUBLIC_HOST%%:*}}"
|
||||
COUNTRY="${XLX_COUNTRY:-RU}"
|
||||
SERVICE_NAME="${XLX_SERVICE_NAME:-xlxd}"
|
||||
DB_NAME="${XLX_DB_NAME:-xlx_server}"
|
||||
DB_USER="${XLX_DB_USER:-xlx_user}"
|
||||
DB_PASSWORD="${XLX_DB_PASSWORD:-$(random_token 32)}"
|
||||
@@ -183,7 +184,7 @@ XLX_MODULES=ABCDEFGHIJKLMNOPQRSTUVWXYZ
|
||||
XLX_INSTALL_PATH=/xlxd
|
||||
XLX_SOURCE_PATH=/usr/src/xlxd
|
||||
XLX_LOG_PATH=/var/log/xlxd.log
|
||||
XLX_SERVICE_NAME=xlxd
|
||||
XLX_SERVICE_NAME=${SERVICE_NAME}
|
||||
XLX_REPO_URL=https://github.com/LX3JL/xlxd.git
|
||||
EOF
|
||||
|
||||
@@ -195,6 +196,16 @@ sed \
|
||||
-e "s#__PUBLIC_DIR__#${PUBLIC_DIR}#g" \
|
||||
-e "s#__PANEL_PORT__#${PANEL_PORT}#g" \
|
||||
"$REPO_ROOT/deploy/apache/xlx-panel.conf" > /etc/apache2/sites-available/xlx-panel.conf
|
||||
|
||||
SYSTEMCTL_PATH="$(command -v systemctl)"
|
||||
cat > /etc/sudoers.d/xlx-panel <<EOF
|
||||
www-data ALL=(root) NOPASSWD: ${SYSTEMCTL_PATH} start ${SERVICE_NAME}
|
||||
www-data ALL=(root) NOPASSWD: ${SYSTEMCTL_PATH} stop ${SERVICE_NAME}
|
||||
www-data ALL=(root) NOPASSWD: ${SYSTEMCTL_PATH} restart ${SERVICE_NAME}
|
||||
www-data ALL=(root) NOPASSWD: ${SYSTEMCTL_PATH} status ${SERVICE_NAME} --no-pager
|
||||
EOF
|
||||
chmod 440 /etc/sudoers.d/xlx-panel
|
||||
|
||||
a2dissite 000-default.conf >/dev/null 2>&1 || true
|
||||
a2ensite xlx-panel.conf
|
||||
systemctl restart apache2
|
||||
|
||||
@@ -56,6 +56,9 @@ install -m 0644 "$REPO_ROOT/deploy/systemd/xlxd.service" "/etc/systemd/system/${
|
||||
if getent group www-data >/dev/null 2>&1; then
|
||||
chgrp www-data "$XLX_INSTALL_PATH" || true
|
||||
chmod 775 "$XLX_INSTALL_PATH" || true
|
||||
touch "${XLX_LOG_PATH:-/var/log/xlxd.log}" || true
|
||||
chgrp www-data "${XLX_LOG_PATH:-/var/log/xlxd.log}" || true
|
||||
chmod 664 "${XLX_LOG_PATH:-/var/log/xlxd.log}" || true
|
||||
for file in xlxd.blacklist xlxd.whitelist xlxd.interlink xlxd.terminal; do
|
||||
if [[ -f "$XLX_INSTALL_PATH/$file" ]]; then
|
||||
chgrp www-data "$XLX_INSTALL_PATH/$file" || true
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Xlx\Domain;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
use Xlx\Support\Config;
|
||||
|
||||
final class XlxdRuntimeService
|
||||
{
|
||||
private const ACTIONS = ['start', 'stop', 'restart', 'status'];
|
||||
|
||||
public function __construct(private readonly Config $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function dashboard(PDO $pdo): array
|
||||
{
|
||||
return [
|
||||
'service' => $this->status(),
|
||||
'counters' => $this->counters($pdo),
|
||||
'latest_events' => $this->latestEvents(20),
|
||||
];
|
||||
}
|
||||
|
||||
public function status(): array
|
||||
{
|
||||
$service = $this->serviceName();
|
||||
$active = trim($this->run('systemctl is-active ' . escapeshellarg($service), false)['output']);
|
||||
$enabled = trim($this->run('systemctl is-enabled ' . escapeshellarg($service), false)['output']);
|
||||
|
||||
return [
|
||||
'service_name' => $service,
|
||||
'active' => $active !== '' ? $active : 'unknown',
|
||||
'enabled' => $enabled !== '' ? $enabled : 'unknown',
|
||||
'log_path' => $this->logPath(),
|
||||
'checked_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
public function control(string $action): array
|
||||
{
|
||||
if (!in_array($action, self::ACTIONS, true)) {
|
||||
throw new RuntimeException('Unsupported xlxd action.');
|
||||
}
|
||||
|
||||
$service = $this->serviceName();
|
||||
$command = $action === 'status'
|
||||
? 'systemctl status ' . escapeshellarg($service) . ' --no-pager'
|
||||
: 'sudo -n systemctl ' . escapeshellarg($action) . ' ' . escapeshellarg($service);
|
||||
|
||||
$result = $this->run($command, false);
|
||||
|
||||
return [
|
||||
'action' => $action,
|
||||
'exit_code' => $result['exit_code'],
|
||||
'output' => $result['output'],
|
||||
'service' => $this->status(),
|
||||
];
|
||||
}
|
||||
|
||||
public function latestEvents(int $limit = 20): array
|
||||
{
|
||||
$path = $this->logPath();
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES);
|
||||
if (!is_array($lines)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lines = array_values(array_filter($lines, static fn (string $line): bool => trim($line) !== ''));
|
||||
$lines = array_slice($lines, -$limit);
|
||||
|
||||
return array_map(static function (string $line): array {
|
||||
return [
|
||||
'time' => self::extractTime($line),
|
||||
'callsign' => self::extractCallsign($line),
|
||||
'message' => $line,
|
||||
];
|
||||
}, array_reverse($lines));
|
||||
}
|
||||
|
||||
private function counters(PDO $pdo): array
|
||||
{
|
||||
return [
|
||||
'users_total' => (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn(),
|
||||
'users_active' => (int) $pdo->query("SELECT COUNT(*) FROM users WHERE status = 'active'")->fetchColumn(),
|
||||
'payments_pending' => (int) $pdo->query("SELECT COUNT(*) FROM payments WHERE status = 'pending'")->fetchColumn(),
|
||||
'payments_paid' => (int) $pdo->query("SELECT COUNT(*) FROM payments WHERE status = 'paid'")->fetchColumn(),
|
||||
];
|
||||
}
|
||||
|
||||
private function run(string $command, bool $throwOnFailure = true): array
|
||||
{
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($command . ' 2>&1', $output, $exitCode);
|
||||
$text = implode("\n", $output);
|
||||
|
||||
if ($throwOnFailure && $exitCode !== 0) {
|
||||
throw new RuntimeException($text !== '' ? $text : 'Command failed.');
|
||||
}
|
||||
|
||||
return [
|
||||
'exit_code' => $exitCode,
|
||||
'output' => $text,
|
||||
];
|
||||
}
|
||||
|
||||
private function serviceName(): string
|
||||
{
|
||||
return preg_replace('/[^A-Za-z0-9_.@-]/', '', (string) $this->config->get('xlx.service_name', 'xlxd')) ?: 'xlxd';
|
||||
}
|
||||
|
||||
private function logPath(): string
|
||||
{
|
||||
return (string) $this->config->get('xlx.log_path', '/var/log/xlxd.log');
|
||||
}
|
||||
|
||||
private static function extractTime(string $line): ?string
|
||||
{
|
||||
if (preg_match('/(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})/', $line, $match)) {
|
||||
return $match[1];
|
||||
}
|
||||
if (preg_match('/([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})/', $line, $match)) {
|
||||
return $match[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function extractCallsign(string $line): ?string
|
||||
{
|
||||
if (preg_match('/\b([A-Z0-9]{2,8}[-\/]?[A-Z0-9]{0,4})\b/', strtoupper($line), $match)) {
|
||||
return $match[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user