Добавить управление рефлектором и дашборд статистики

Этот коммит содержится в:
2026-05-18 07:08:08 +09:00
родитель 2f80c4322c
Коммит 837541b022
11 изменённых файлов: 421 добавлений и 16 удалений
+70 -5
Просмотреть файл
@@ -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', () => {
+44
Просмотреть файл
@@ -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();
+65
Просмотреть файл
@@ -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 {
+16
Просмотреть файл
@@ -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);
+29 -8
Просмотреть файл
@@ -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>
+22 -2
Просмотреть файл
@@ -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>Позывной