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...
+
Активные...
+
Оплачено...
+
+
+
+
+
+
+
+
+
+