feat(settings): блок «Статус стека» — на чём всё крутится
На странице Настроек появился блок с состоянием стека системы: версии компонентов, состояние сервисов и нагрузка. Рендерится из нового блока stack в ответе GET /api/system-info, в том же стиле, что и соседний блок системной информации (sys-card / sys-item / sys-bar, точки worker-dot, полосы загрузки и памяти с порогами 70 % и 90 %). Карточки: Приложение (версия и коммит, Node.js, PID и RSS, аптайм, куча, сворачиваемый список библиотек), Сервер (ОС, ядро, архитектура и число ядер, модель CPU, loadavg, память, аптайм, контейнер), База данных (PostgreSQL и версия, хост, состояние пула), Кэш (драйвер, версия Redis, ключи, память, попадания и промахи, операций в памяти) и Хранилище (драйвер, endpoint, бакет или каталог). - server.js: getStackInfo() — версии из package.json, node_modules и public/version.json, ОС из /etc/os-release, определение Docker, SHOW server_version, счётчики пула pg, состояние Redis. Считается вне cacheWrap, чтобы версии и нагрузка не отдавались из 30-секундного кэша; хосты только через URL.hostname, без учётных данных URL - settings.html: карточка sec-stack и пункт «Стек» в навигации - settings.js: renderStackInfo(), форматирование аптаймов с русскими склонениями, degrade-состояние «нет связи — в памяти»; кнопка обновления перезагружает оба блока одним запросом - admin.css: .sys-inline для точки статуса рядом с текстом - api.smoketest.js: контракт блока stack и проверка, что в ответе нет учётных данных из DATABASE_URL/REDIS_URL
This commit is contained in:
@@ -394,6 +394,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.sys-bar-wrap{height:6px;background:var(--border);border-radius:3px;overflow:hidden;margin-top:4px}
|
||||
.sys-bar{height:100%;transition:width .3s,background .3s}
|
||||
.sys-meta{font-size:.7rem;color:var(--muted);margin-top:4px}
|
||||
.sys-inline{display:inline-flex;align-items:center;gap:7px;justify-content:flex-end}
|
||||
|
||||
.actions-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
.audit-table{width:100%;border-collapse:collapse;font-size:.85rem}
|
||||
|
||||
+155
-9
@@ -1,5 +1,5 @@
|
||||
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'trashPurgeDays'];
|
||||
const SECTION_IDS = ['sec-system', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-bans'];
|
||||
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-bans'];
|
||||
const SPAM_MAX = 10080;
|
||||
const BAN_REASONS = {
|
||||
'honeypot': 'Антиспам-поле',
|
||||
@@ -10,6 +10,7 @@ const BAN_REASONS = {
|
||||
|
||||
let currentLogo = '';
|
||||
let logoOriginal = '';
|
||||
let currentSystemName = '';
|
||||
|
||||
function renderLogoPreview() {
|
||||
const img = document.getElementById('logoPreviewImg');
|
||||
@@ -161,6 +162,7 @@ async function loadSettings() {
|
||||
if (!sRes.ok) { showError('Не удалось загрузить настройки'); return; }
|
||||
const s = await sRes.json();
|
||||
document.getElementById('systemName').value = s.system_name !== undefined ? s.system_name : 'WhatIDo';
|
||||
currentSystemName = s.system_name || 'WhatIDo';
|
||||
currentLogo = s.system_logo || '';
|
||||
logoOriginal = currentLogo;
|
||||
renderLogoPreview();
|
||||
@@ -216,20 +218,32 @@ async function refreshStats() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSystemInfo() {
|
||||
async function loadSystemInfo(btn) {
|
||||
const el = document.getElementById('systemInfo');
|
||||
if (!el) return;
|
||||
if (btn) btn.classList.add('spinning');
|
||||
try {
|
||||
const res = await fetch(`${API}/api/system-info`, { headers: hdr() });
|
||||
if (res.ok) {
|
||||
renderSystemInfo(await res.json());
|
||||
const data = await res.json();
|
||||
renderSystemInfo(data);
|
||||
renderStackInfo(data.stack);
|
||||
const stamp = 'обновлено ' + new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
|
||||
const live = document.getElementById('systemLive');
|
||||
if (live) live.textContent = 'обновлено ' + new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
|
||||
if (live) live.textContent = stamp;
|
||||
const stackLive = document.getElementById('stackLive');
|
||||
if (stackLive) stackLive.textContent = stamp;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
|
||||
const st = document.getElementById('stackInfo');
|
||||
if (st) st.innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
|
||||
} catch {
|
||||
el.innerHTML = '<div class="sys-card err">Ошибка сети</div>';
|
||||
const st = document.getElementById('stackInfo');
|
||||
if (st) st.innerHTML = '<div class="sys-card err">Ошибка сети</div>';
|
||||
} finally {
|
||||
if (btn) btn.classList.remove('spinning');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +309,139 @@ function renderSystemInfo(si) {
|
||||
`;
|
||||
}
|
||||
|
||||
function fmtDuration(sec) {
|
||||
const s = Math.max(0, Math.round(Number(sec) || 0));
|
||||
if (!s) return '—';
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (d) return `${d} ${plural(d, 'день', 'дня', 'дней')} ${h} ${plural(h, 'час', 'часа', 'часов')}`;
|
||||
if (h) return `${h} ${plural(h, 'час', 'часа', 'часов')} ${m} ${plural(m, 'минута', 'минуты', 'минут')}`;
|
||||
if (m) return `${m} ${plural(m, 'минута', 'минуты', 'минут')}`;
|
||||
return `${s} ${plural(s, 'секунда', 'секунды', 'секунд')}`;
|
||||
}
|
||||
|
||||
function stackState(cls, text) {
|
||||
return `<span class="sys-inline"><span class="worker-dot ${cls}"></span>${esc(text)}</span>`;
|
||||
}
|
||||
|
||||
function stackBar(pct, color) {
|
||||
const p = Math.min(100, Math.max(0, Math.round(pct || 0)));
|
||||
return `<div class="sys-bar-wrap"><div class="sys-bar" style="width:${p}%; background:${color}"></div></div>`;
|
||||
}
|
||||
|
||||
function stackRow(label, value) {
|
||||
if (value === null || value === undefined || value === '') return '';
|
||||
return `<div class="sys-item"><span class="sys-label">${esc(label)}</span><span class="sys-value">${value}</span></div>`;
|
||||
}
|
||||
|
||||
function renderStackInfo(stack) {
|
||||
const el = document.getElementById('stackInfo');
|
||||
if (!el) return;
|
||||
if (!stack) {
|
||||
el.innerHTML = '<div class="sys-card err">Стек недоступен</div>';
|
||||
return;
|
||||
}
|
||||
const app = stack.app || {};
|
||||
const rt = stack.runtime || {};
|
||||
const db = stack.database || {};
|
||||
const cache = stack.cache || {};
|
||||
const st = stack.storage || {};
|
||||
const deps = stack.deps || {};
|
||||
const load = Array.isArray(rt.cpu_load) ? rt.cpu_load : [];
|
||||
const cpus = rt.cpus || 0;
|
||||
|
||||
let memPct = 0;
|
||||
if (rt.mem_total_bytes) memPct = Math.round(((rt.mem_total_bytes - (rt.mem_free_bytes || 0)) / rt.mem_total_bytes) * 100);
|
||||
const memColor = memPct > 90 ? '#ef4444' : memPct > 70 ? '#f59e0b' : '#16a34a';
|
||||
|
||||
const loadPct = cpus && load[0] ? Math.min(100, (load[0] / cpus) * 100) : 0;
|
||||
const loadColor = loadPct > 90 ? '#ef4444' : loadPct > 60 ? '#f59e0b' : '#16a34a';
|
||||
|
||||
const depsList = Object.entries(deps).filter(([, v]) => v);
|
||||
const depsHtml = depsList.length
|
||||
? '<details class="sys-details"><summary>Библиотеки</summary><div class="sys-table-list">' +
|
||||
depsList.map(([name, v]) => `<div class="sys-table-row"><span>${esc(name)}</span><span>${esc(v)}</span></div>`).join('') +
|
||||
'</div></details>'
|
||||
: '';
|
||||
|
||||
const appTitle = [currentSystemName || app.name, app.version].filter(Boolean).join(' ');
|
||||
const build = app.commit
|
||||
? `${esc(appTitle)} · <span title="${esc(app.commit_date || '')}">${esc(app.commit)}</span>`
|
||||
: esc(appTitle);
|
||||
|
||||
const cacheState = cache.driver === 'redis' && cache.ready
|
||||
? stackState('on', 'подключён')
|
||||
: cache.enabled
|
||||
? stackState('err', 'нет связи — в памяти')
|
||||
: stackState('warn', 'отключён — в памяти');
|
||||
|
||||
const cacheTitle = cache.driver === 'redis'
|
||||
? `Redis${cache.version ? ' ' + cache.version : ''}`
|
||||
: 'In-memory';
|
||||
|
||||
const s3 = st.driver === 's3';
|
||||
const where = s3
|
||||
? stackRow('Бакет', esc(`${st.bucket || '—'}${st.region ? ` · ${st.region}` : ''}`))
|
||||
: stackRow('Каталог', esc(st.dir || '—'));
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Приложение</div>
|
||||
${stackRow('Версия', build)}
|
||||
${stackRow('Node.js', esc(app.node || '—'))}
|
||||
${stackRow('Процесс', esc(`PID ${app.pid} · ${app.rss || '—'}`))}
|
||||
${stackRow('Аптайм', esc(fmtDuration(app.uptime_s)))}
|
||||
<div class="sys-meta">Куча: ${esc(app.heap_used || '—')}${app.name ? ` · пакет ${esc(app.name)}` : ''}</div>
|
||||
${depsHtml}
|
||||
</div>
|
||||
|
||||
<div class="sys-card${memPct > 90 ? ' danger' : memPct > 70 ? ' warn' : ''}">
|
||||
<div class="sys-title">Сервер</div>
|
||||
${stackRow('ОС', esc(rt.os || '—'))}
|
||||
${stackRow('Ядро', esc(rt.kernel || '—'))}
|
||||
${stackRow('Архитектура', esc(`${rt.arch || '—'} · ${cpus || '—'} ядер`))}
|
||||
${rt.cpu_model ? `<div class="sys-meta">${esc(rt.cpu_model)}</div>` : ''}
|
||||
${stackRow('Загрузка', esc(load.length ? load.map(v => v.toFixed(2)).join(' / ') : '—'))}
|
||||
${load.length ? stackBar(loadPct, loadColor) : ''}
|
||||
${stackRow('Память', esc(`${rt.mem_free || '—'} свободно из ${rt.mem_total || '—'}`))}
|
||||
${stackBar(memPct, memColor)}
|
||||
${stackRow('Аптайм', esc(fmtDuration(rt.uptime_s)))}
|
||||
${stackRow('Среда', rt.container ? esc(rt.container) : esc('без контейнера'))}
|
||||
</div>
|
||||
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">База данных</div>
|
||||
${stackRow('Состояние', db.version ? stackState('on', 'отвечает') : stackState('err', 'нет связи'))}
|
||||
${stackRow('Движок', esc(`${db.engine || 'PostgreSQL'}${db.version ? ' ' + db.version : ''}`))}
|
||||
${stackRow('Адрес', esc(db.host || '—'))}
|
||||
${stackRow('Соединения', esc(`${db.pool_idle ?? 0} свободно из ${db.pool_total ?? 0}${db.pool_waiting ? ` · в очереди ${db.pool_waiting}` : ''}`))}
|
||||
</div>
|
||||
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Кэш</div>
|
||||
${stackRow('Состояние', cacheState)}
|
||||
${stackRow('Движок', esc(cacheTitle))}
|
||||
${stackRow('Адрес', esc(cache.host || '—'))}
|
||||
${stackRow('Ключей', esc(cache.keys === null || cache.keys === undefined ? '—' : String(cache.keys)))}
|
||||
${stackRow('Память', esc(cache.used_memory || '—'))}
|
||||
${stackRow('Попадания / промахи', esc(`${cache.hits ?? 0} / ${cache.misses ?? 0}`))}
|
||||
${cache.fallback_ops ? stackRow('Операций в памяти', esc(String(cache.fallback_ops))) : ''}
|
||||
${stackRow('Аптайм', esc(fmtDuration(cache.uptime_s)))}
|
||||
</div>
|
||||
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Хранилище</div>
|
||||
${stackRow('Состояние', stackState('on', s3 ? 'S3-совместимое' : 'локальные файлы'))}
|
||||
${stackRow('Драйвер', esc(s3 ? 's3' : 'local'))}
|
||||
${s3 && st.endpoint ? stackRow('Адрес', esc(st.endpoint)) : ''}
|
||||
${where}
|
||||
${stackRow('Локальный кеш', s3 ? esc(st.local_fallback ? 'включён' : 'выключен') : esc('—'))}
|
||||
${s3 ? stackRow('Локальные копии', esc(st.keep_local ? 'хранятся' : 'удаляются')) : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadAiProfiles() {
|
||||
const listEl = document.getElementById('aiProfilesList');
|
||||
const select = document.getElementById('aiActiveProfile');
|
||||
@@ -683,11 +830,10 @@ document.getElementById('saveBtn').addEventListener('click', saveSettings);
|
||||
document.getElementById('resetBtn').addEventListener('click', revertChanges);
|
||||
|
||||
const systemRefreshBtn = document.getElementById('systemRefreshBtn');
|
||||
if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', async () => {
|
||||
systemRefreshBtn.classList.add('spinning');
|
||||
await loadSystemInfo();
|
||||
systemRefreshBtn.classList.remove('spinning');
|
||||
});
|
||||
if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', () => loadSystemInfo(systemRefreshBtn));
|
||||
|
||||
const stackRefreshBtn = document.getElementById('stackRefreshBtn');
|
||||
if (stackRefreshBtn) stackRefreshBtn.addEventListener('click', () => loadSystemInfo(stackRefreshBtn));
|
||||
|
||||
const statsRefreshBtn = document.getElementById('statsRefreshBtn');
|
||||
if (statsRefreshBtn) statsRefreshBtn.addEventListener('click', async () => {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<div class="settings-layout">
|
||||
<aside class="settings-nav" id="settingsNav">
|
||||
<a href="#sec-system" data-nav="sec-system" class="active"><i data-lucide="bar-chart-3"></i> Система</a>
|
||||
<a href="#sec-stack" data-nav="sec-stack"><i data-lucide="server"></i> Стек</a>
|
||||
<a href="#sec-brand" data-nav="sec-brand"><i data-lucide="palette"></i> Брендинг</a>
|
||||
<a href="#sec-antispam" data-nav="sec-antispam"><i data-lucide="shield"></i> Антиспам</a>
|
||||
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
|
||||
@@ -44,6 +45,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card wide" id="sec-stack">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="server"></i></div>
|
||||
<h3>Статус стека</h3>
|
||||
<button class="icon-btn" id="stackRefreshBtn" type="button" title="Обновить" aria-label="Обновить стек"><i data-lucide="refresh-cw"></i></button>
|
||||
<span class="head-live" id="stackLive" title="Версии и состояние сервисов"></span>
|
||||
</div>
|
||||
<p class="hint">На чём работает приложение прямо сейчас: версии компонентов, состояние сервисов и нагрузка. Обновление перезагружает данные и этого блока, и системной информации выше.</p>
|
||||
<div id="stackInfo" class="sys-grid">
|
||||
<div class="sys-card loading">Загрузка…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="sec-brand">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="palette"></i></div>
|
||||
|
||||
Reference in New Issue
Block a user