Compare commits

...
2 Commits
Author SHA1 Message Date
dev f31b8deea2 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
2026-09-27 12:47:04 +03:00
dev 4d3df1de37 fix(redis): разбирать INFO по разделителю, а не по фиксированному смещению
used_memory и used_memory_human вырезались на символ длиннее нужного,
поэтому первая цифра терялась: 1024 превращалось в 24, а "1.34M" — в
".34M". Значение памяти Redis в /api/system-info и в self-тестах было
занижено на порядок.

- parseInfoSections(): разбор INFO по indexOf(':'), пропуск заголовков
  # и пустых строк, работа с любой секцией
- info(): добавлены redis_version и uptime_in_seconds (секция server),
  в том числе в ветках memory и ошибки — с null
2026-09-27 12:46:41 +03:00
8 changed files with 366 additions and 23 deletions
+9
View File
@@ -189,6 +189,15 @@ otherwise the list endpoint ships kilobytes of text per row.
Verify Redis state through `GET /api/system-info` → `cache` (`driver`, `ready`, `hits`, `misses`,
`fallbackOps`, `used_memory_human`, `keys`).
`GET /api/system-info` also returns a `stack` block (built by `getStackInfo()`, outside the
response cache so versions and load stay fresh): `app` (Node, PID, RSS/heap, uptime, version from
`package.json` + `public/version.json`), `deps` (installed versions of the main packages), `runtime`
(OS from `/etc/os-release`, kernel, arch, CPU count/model, loadavg, memory, container detection),
`database` (PostgreSQL version, host, pool counters), `cache` (driver, version, ready, keys, memory,
hits/misses, fallback ops) and `storage` (driver, endpoint, bucket). Hosts come from `URL.hostname`
only — credentials from `DATABASE_URL`/`REDIS_URL` must never reach the payload. The «Статус стека»
block on `public/settings.html` renders exactly this payload.
`api.smoketest.js` also locks the auth contract: only `X-Auth-Token` with a session token
authenticates, while `X-Admin-Token`, `Authorization: Bearer` and `ADMIN_PASSWORD` used as a
token must all be rejected with 401. If you change the auth scheme, update this test and the
+3 -2
View File
@@ -13,7 +13,7 @@
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
- **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции
- **Настройки** — тексты футера, анти-спам интервал
- **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`)
- **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido.<tailnet>.ts.net` без проброса портов, внешнего IP и reverse-proxy
## Технологии
@@ -376,7 +376,8 @@ docker compose exec -T app node scripts/migrate-to-s3.js --delete-local
in-memory кэш (та же семантика и те же ключи) и возвращается в Redis автоматически, как только
сервис поднимется. Первое подключение ограничено таймаутом `REDIS_CONNECT_TIMEOUT_MS` (5 с по
умолчанию), поэтому недоступный Redis не задержит старт приложения. Текущее состояние видно в
`GET /api/system-info` → `cache.driver` (`redis` или `memory`).
`GET /api/system-info` → `cache.driver` (`redis` или `memory`) и в блоке «Кэш» на странице
Настроек → Стек (там же — «нет связи — в памяти», если Redis не отвечает).
### Команды
+4
View File
@@ -91,6 +91,10 @@ async function main() {
ok('system-info', info.status === 200, info.status);
ok('system-info reports redis driver', info.data && info.data.cache && info.data.cache.driver === 'redis', info.data && info.data.cache);
ok('system-info reports cache hits', info.data && info.data.cache && info.data.cache.hits > 0, info.data && info.data.cache && info.data.cache.hits);
const stack = info.data && info.data.stack;
ok('system-info reports stack', Boolean(stack && stack.app && stack.runtime && stack.database && stack.cache && stack.storage), stack);
ok('stack reports node and postgres version', Boolean(stack && stack.app.node && stack.database.version), stack && { node: stack.app.node, pg: stack.database.version });
ok('stack never leaks credentials', !/:\/\/[^"@/]*@/.test(JSON.stringify(stack)), stack && stack.database.host, stack && stack.cache.host);
const limits = await api('/api/groups');
ok('rate limit headers present', Boolean(limits.headers.get('ratelimit-limit')), {
+1
View File
@@ -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
View File
@@ -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 () => {
+14
View File
@@ -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>
+43 -11
View File
@@ -17,6 +17,20 @@ return hits
const SCAN_BATCH = 200;
function parseInfoSections(chunks) {
const out = new Map();
for (const chunk of chunks) {
for (const line of String(chunk || '').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const sep = trimmed.indexOf(':');
if (sep < 1) continue;
out.set(trimmed.slice(0, sep).trim(), trimmed.slice(sep + 1).trim());
}
}
return out;
}
function createMemoryBackend() {
const store = new Map();
const listeners = new Map();
@@ -184,25 +198,43 @@ function createRedis({ url, prefix } = {}) {
async info() {
const base = { ...stats, enabled: enabled(), ready: ready(), namespace };
if (!ready()) return { ...base, driver: 'memory', used_memory_bytes: null, used_memory_human: null, keys: null };
const offline = {
...base,
driver: 'memory',
version: null,
server_uptime_s: null,
used_memory_bytes: null,
used_memory_human: null,
keys: null,
};
if (!ready()) return offline;
try {
const [raw, dbsize] = await Promise.all([client.info('memory'), client.dbSize()]);
let used = null;
let human = null;
for (const line of String(raw || '').split('\n')) {
if (line.startsWith('used_memory:')) used = parseInt(line.slice(13).trim(), 10) || null;
else if (line.startsWith('used_memory_human:')) human = line.slice(19).trim();
}
const [raw, dbsize, server] = await Promise.all([
client.info('memory'),
client.dbSize(),
client.info('server'),
]);
const fields = parseInfoSections([raw, server]);
return {
...base,
driver: 'redis',
used_memory_bytes: used,
used_memory_human: human,
version: fields.get('redis_version') || null,
server_uptime_s: fields.has('uptime_in_seconds') ? Number(fields.get('uptime_in_seconds')) || null : null,
used_memory_bytes: fields.has('used_memory') ? Number(fields.get('used_memory')) || null : null,
used_memory_human: fields.get('used_memory_human') || null,
keys: Number(dbsize) || 0,
};
} catch (e) {
noteError('info', e);
return { ...base, driver: 'redis', used_memory_bytes: null, used_memory_human: null, keys: null };
return {
...base,
driver: 'redis',
version: null,
server_uptime_s: null,
used_memory_bytes: null,
used_memory_human: null,
keys: null,
};
}
},
+137 -1
View File
@@ -448,6 +448,142 @@ function getDiskInfo() {
return null;
}
let appMeta = null;
function getAppMeta() {
if (appMeta) return appMeta;
appMeta = { name: 'WhatIDo', version: '', commit: '', commit_date: '' };
try {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
if (pkg.name) appMeta.name = pkg.name;
if (pkg.version) appMeta.version = pkg.version;
} catch {}
try {
const v = JSON.parse(fs.readFileSync(path.join(__dirname, 'public', 'version.json'), 'utf8'));
appMeta.commit = v.short || v.full || '';
appMeta.commit_date = v.date || '';
} catch {}
return appMeta;
}
function getDepVersion(name) {
try {
return JSON.parse(fs.readFileSync(path.join(__dirname, 'node_modules', name, 'package.json'), 'utf8')).version || null;
} catch {
return null;
}
}
const STACK_DEPS = ['express', 'pg', 'redis', 'sharp', 'multer', 'tar', 'helmet', 'bcrypt', '@aws-sdk/client-s3'];
let depVersions = null;
function getDepVersions() {
if (!depVersions) {
depVersions = {};
for (const name of STACK_DEPS) depVersions[name] = getDepVersion(name);
}
return depVersions;
}
function hostOf(value) {
if (!value) return null;
try {
const u = new URL(value);
return u.port ? `${u.hostname}:${u.port}` : u.hostname;
} catch {
return null;
}
}
function getRuntimeInfo() {
const cpus = os.cpus() || [];
const release = {};
try {
for (const line of fs.readFileSync('/etc/os-release', 'utf8').split('\n')) {
const sep = line.indexOf('=');
if (sep > 0) release[line.slice(0, sep)] = line.slice(sep + 1).replace(/^"|"$/g, '');
}
} catch {}
let container = null;
try {
if (fs.existsSync('/.dockerenv')) container = 'Docker';
else if (/docker|containerd|kubepods/.test(fs.readFileSync('/proc/1/cgroup', 'utf8'))) container = 'Docker';
else if (process.env.KUBERNETES_SERVICE_HOST) container = 'Kubernetes';
} catch {}
return {
container,
os: release.PRETTY_NAME || [release.NAME, release.VERSION_ID].filter(Boolean).join(' ') || os.platform(),
os_version: release.VERSION_ID || null,
kernel: `${os.platform()} ${os.release()}`,
arch: os.arch(),
cpus: cpus.length,
cpu_model: cpus[0] ? String(cpus[0].model || '').trim() || null : null,
cpu_load: os.loadavg().map(v => Math.round(v * 100) / 100),
mem_total: formatBytes(os.totalmem()),
mem_free: formatBytes(os.freemem()),
mem_total_bytes: os.totalmem(),
mem_free_bytes: os.freemem(),
uptime_s: Math.round(os.uptime()),
};
}
async function getStackInfo(cacheStats) {
const app = getAppMeta();
const mem = process.memoryUsage();
let dbVersion = null;
try {
const r = await pool.query('SHOW server_version');
dbVersion = r.rows[0] ? String(r.rows[0].server_version) : null;
} catch {}
const remote = storage.isRemote();
return {
app: {
name: app.name,
version: app.version,
commit: app.commit,
commit_date: app.commit_date,
node: process.version,
pid: process.pid,
uptime_s: Math.round(process.uptime()),
rss: formatBytes(mem.rss),
heap_used: formatBytes(mem.heapUsed),
},
deps: getDepVersions(),
runtime: getRuntimeInfo(),
database: {
engine: 'PostgreSQL',
version: dbVersion,
host: hostOf(process.env.DATABASE_URL),
pool_total: pool.totalCount,
pool_idle: pool.idleCount,
pool_waiting: pool.waitingCount,
},
cache: {
engine: 'Redis',
driver: (cacheStats && cacheStats.driver) || null,
version: (cacheStats && cacheStats.version) || null,
ready: !!(cacheStats && cacheStats.ready),
enabled: cacheStats ? !!cacheStats.enabled : null,
host: hostOf(process.env.REDIS_URL),
keys: cacheStats ? cacheStats.keys : null,
used_memory: (cacheStats && cacheStats.used_memory_human) || null,
uptime_s: cacheStats ? cacheStats.server_uptime_s : null,
hits: cacheStats ? cacheStats.hits : null,
misses: cacheStats ? cacheStats.misses : null,
fallback_ops: cacheStats ? cacheStats.fallbackOps : null,
errors: cacheStats ? cacheStats.errors : null,
},
storage: {
engine: remote ? 'S3' : 'Файловая система',
driver: remote ? 's3' : 'local',
endpoint: hostOf(process.env.S3_ENDPOINT),
bucket: remote ? (process.env.S3_BUCKET || null) : null,
region: remote ? (process.env.S3_REGION || null) : null,
dir: remote ? null : UPLOADS_DIR,
local_fallback: process.env.STORAGE_LOCAL_FALLBACK === '1',
keep_local: process.env.STORAGE_KEEP_LOCAL === '1',
},
};
}
function safeUser(u) {
return {
id: u.id,
@@ -4006,7 +4142,7 @@ app.get('/api/system-info', requireAdmin, async (_, res) => {
cache: cacheStats,
};
});
res.json(payload);
res.json({ ...payload, stack: await getStackInfo(payload.cache) });
} catch (e) {
console.error('System info error:', e);
res.status(500).json({ error: e.message });