From 837541b0221c2baa234c0cb24f47236171dd2a03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D0=BA=D1=82=D0=BE=D1=80?= Date: Mon, 18 May 2026 07:08:08 +0900 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=80=D0=B5=D1=84=D0=BB=D0=B5=D0=BA=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=BC=20=D0=B8=20=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=B4=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + docs/ADMIN_DASHBOARD.md | 13 +++ public/assets/admin.js | 75 ++++++++++++++-- public/assets/app.js | 44 +++++++++ public/assets/styles.css | 65 ++++++++++++++ public/index.php | 16 ++++ public/views/admin.php | 37 ++++++-- public/views/home.php | 24 ++++- scripts/install-system.sh | 13 ++- scripts/install-xlxd.sh | 3 + src/Domain/XlxdRuntimeService.php | 145 ++++++++++++++++++++++++++++++ 11 files changed, 421 insertions(+), 16 deletions(-) create mode 100644 src/Domain/XlxdRuntimeService.php diff --git a/README.md b/README.md index 1251e4f..53d6065 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,9 @@ - установка и управление `xlxd`; - главный пользовательский дашборд `/`; - админка настроек `xlxd` `/admin`; +- управление `xlxd` из админки: status/start/stop/restart; - графический редактор `/xlxd/xlxd.blacklist`, `xlxd.whitelist`, `xlxd.interlink`, `xlxd.terminal`; +- статистика последних событий и вызовов на главной; - экспорт активных пользователей для будущего access gateway. ## Компоненты diff --git a/docs/ADMIN_DASHBOARD.md b/docs/ADMIN_DASHBOARD.md index e0e1891..72defb4 100644 --- a/docs/ADMIN_DASHBOARD.md +++ b/docs/ADMIN_DASHBOARD.md @@ -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`. diff --git a/public/assets/admin.js b/public/assets/admin.js index fcb6c57..d2b7467 100644 --- a/public/assets/admin.js +++ b/public/assets/admin.js @@ -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 = ` +
Сервис${service.active || 'unknown'}
+
Enabled${service.enabled || 'unknown'}
+
Активные${counters.users_active ?? 0}
+
Оплачено${counters.payments_paid ?? 0}
+ `; + + 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', () => { diff --git a/public/assets/app.js b/public/assets/app.js index 6f0ca10..28b6ad0 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -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 = ` +
Сервис${service.active || 'unknown'}
+
Активные${counters.users_active ?? 0}
+
Пользователи${counters.users_total ?? 0}
+
Ожидают оплату${counters.payments_pending ?? 0}
+ `; + + const events = data.latest_events || []; + if (events.length === 0) { + eventList.innerHTML = '
Пока нет событий в логе или лог недоступен.
'; + return; + } + + eventList.innerHTML = events.map((event) => ` +
+ ${event.time || 'без времени'} + ${event.callsign || 'XLX'} +

${event.message}

+
+ `).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 = `
${error.message}
`; + } +} + +refreshStats?.addEventListener('click', loadStats); +loadStats(); form?.addEventListener('submit', async (event) => { event.preventDefault(); diff --git a/public/assets/styles.css b/public/assets/styles.css index 8f47238..847d2b4 100644 --- a/public/assets/styles.css +++ b/public/assets/styles.css @@ -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 { diff --git a/public/index.php b/public/index.php index dc13ae5..c243ff7 100644 --- a/public/index.php +++ b/public/index.php @@ -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); diff --git a/public/views/admin.php b/public/views/admin.php index 13c69b4..42d5be0 100644 --- a/public/views/admin.php +++ b/public/views/admin.php @@ -11,8 +11,8 @@

Админка

-

Настройка XLX без ручного редактирования конфигов

-

Меняй имя reflector, домен, порты, модули и пути. Настройки сохраняются в БД и могут сразу применяться в `/etc/xlx/xlxd.env`.

+

Полная настройка XLX reflector

+

Настраивай параметры `xlxd`, управляй сервисом, редактируй blacklist, whitelist, interlink и terminal без ручного SSH-редактирования.

@@ -23,16 +23,40 @@
- +
+
+
+
+

Управление reflector

+

Статус и перезапуск `xlxd`

+

После изменения env или файлов XLX можно перезапустить сервис прямо отсюда.

+
+ +
+
+
Сервис...
+
Enabled...
+
Активные...
+
Оплачено...
+
+
+ + + + +
+

+    
+
-