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`, Verify Redis state through `GET /api/system-info` → `cache` (`driver`, `ready`, `hits`, `misses`,
`fallbackOps`, `used_memory_human`, `keys`). `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 `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 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 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 дней, последние записи, топ воспитанников - **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz` - **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
- **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции - **Хранилище файлов** — локальный каталог `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 - **Публикация через 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 автоматически, как только in-memory кэш (та же семантика и те же ключи) и возвращается в Redis автоматически, как только
сервис поднимется. Первое подключение ограничено таймаутом `REDIS_CONNECT_TIMEOUT_MS` (5 с по сервис поднимется. Первое подключение ограничено таймаутом `REDIS_CONNECT_TIMEOUT_MS` (5 с по
умолчанию), поэтому недоступный Redis не задержит старт приложения. Текущее состояние видно в умолчанию), поэтому недоступный 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', 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 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); 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'); const limits = await api('/api/groups');
ok('rate limit headers present', Boolean(limits.headers.get('ratelimit-limit')), { 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-wrap{height:6px;background:var(--border);border-radius:3px;overflow:hidden;margin-top:4px}
.sys-bar{height:100%;transition:width .3s,background .3s} .sys-bar{height:100%;transition:width .3s,background .3s}
.sys-meta{font-size:.7rem;color:var(--muted);margin-top:4px} .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} .actions-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.audit-table{width:100%;border-collapse:collapse;font-size:.85rem} .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 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 SPAM_MAX = 10080;
const BAN_REASONS = { const BAN_REASONS = {
'honeypot': 'Антиспам-поле', 'honeypot': 'Антиспам-поле',
@@ -10,6 +10,7 @@ const BAN_REASONS = {
let currentLogo = ''; let currentLogo = '';
let logoOriginal = ''; let logoOriginal = '';
let currentSystemName = '';
function renderLogoPreview() { function renderLogoPreview() {
const img = document.getElementById('logoPreviewImg'); const img = document.getElementById('logoPreviewImg');
@@ -161,6 +162,7 @@ async function loadSettings() {
if (!sRes.ok) { showError('Не удалось загрузить настройки'); return; } if (!sRes.ok) { showError('Не удалось загрузить настройки'); return; }
const s = await sRes.json(); const s = await sRes.json();
document.getElementById('systemName').value = s.system_name !== undefined ? s.system_name : 'WhatIDo'; document.getElementById('systemName').value = s.system_name !== undefined ? s.system_name : 'WhatIDo';
currentSystemName = s.system_name || 'WhatIDo';
currentLogo = s.system_logo || ''; currentLogo = s.system_logo || '';
logoOriginal = currentLogo; logoOriginal = currentLogo;
renderLogoPreview(); renderLogoPreview();
@@ -216,20 +218,32 @@ async function refreshStats() {
} }
} }
async function loadSystemInfo() { async function loadSystemInfo(btn) {
const el = document.getElementById('systemInfo'); const el = document.getElementById('systemInfo');
if (!el) return; if (!el) return;
if (btn) btn.classList.add('spinning');
try { try {
const res = await fetch(`${API}/api/system-info`, { headers: hdr() }); const res = await fetch(`${API}/api/system-info`, { headers: hdr() });
if (res.ok) { 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'); 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; return;
} }
el.innerHTML = '<div class="sys-card err">Ошибка загрузки</div>'; el.innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
const st = document.getElementById('stackInfo');
if (st) st.innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
} catch { } catch {
el.innerHTML = '<div class="sys-card err">Ошибка сети</div>'; 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() { async function loadAiProfiles() {
const listEl = document.getElementById('aiProfilesList'); const listEl = document.getElementById('aiProfilesList');
const select = document.getElementById('aiActiveProfile'); const select = document.getElementById('aiActiveProfile');
@@ -683,11 +830,10 @@ document.getElementById('saveBtn').addEventListener('click', saveSettings);
document.getElementById('resetBtn').addEventListener('click', revertChanges); document.getElementById('resetBtn').addEventListener('click', revertChanges);
const systemRefreshBtn = document.getElementById('systemRefreshBtn'); const systemRefreshBtn = document.getElementById('systemRefreshBtn');
if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', async () => { if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', () => loadSystemInfo(systemRefreshBtn));
systemRefreshBtn.classList.add('spinning');
await loadSystemInfo(); const stackRefreshBtn = document.getElementById('stackRefreshBtn');
systemRefreshBtn.classList.remove('spinning'); if (stackRefreshBtn) stackRefreshBtn.addEventListener('click', () => loadSystemInfo(stackRefreshBtn));
});
const statsRefreshBtn = document.getElementById('statsRefreshBtn'); const statsRefreshBtn = document.getElementById('statsRefreshBtn');
if (statsRefreshBtn) statsRefreshBtn.addEventListener('click', async () => { if (statsRefreshBtn) statsRefreshBtn.addEventListener('click', async () => {
+14
View File
@@ -20,6 +20,7 @@
<div class="settings-layout"> <div class="settings-layout">
<aside class="settings-nav" id="settingsNav"> <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-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-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-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> <a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
@@ -44,6 +45,19 @@
</div> </div>
</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="settings-card" id="sec-brand">
<div class="card-head"> <div class="card-head">
<div class="ic"><i data-lucide="palette"></i></div> <div class="ic"><i data-lucide="palette"></i></div>
+43 -11
View File
@@ -17,6 +17,20 @@ return hits
const SCAN_BATCH = 200; 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() { function createMemoryBackend() {
const store = new Map(); const store = new Map();
const listeners = new Map(); const listeners = new Map();
@@ -184,25 +198,43 @@ function createRedis({ url, prefix } = {}) {
async info() { async info() {
const base = { ...stats, enabled: enabled(), ready: ready(), namespace }; 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 { try {
const [raw, dbsize] = await Promise.all([client.info('memory'), client.dbSize()]); const [raw, dbsize, server] = await Promise.all([
let used = null; client.info('memory'),
let human = null; client.dbSize(),
for (const line of String(raw || '').split('\n')) { client.info('server'),
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 fields = parseInfoSections([raw, server]);
}
return { return {
...base, ...base,
driver: 'redis', driver: 'redis',
used_memory_bytes: used, version: fields.get('redis_version') || null,
used_memory_human: human, 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, keys: Number(dbsize) || 0,
}; };
} catch (e) { } catch (e) {
noteError('info', 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; 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) { function safeUser(u) {
return { return {
id: u.id, id: u.id,
@@ -4006,7 +4142,7 @@ app.get('/api/system-info', requireAdmin, async (_, res) => {
cache: cacheStats, cache: cacheStats,
}; };
}); });
res.json(payload); res.json({ ...payload, stack: await getStackInfo(payload.cache) });
} catch (e) { } catch (e) {
console.error('System info error:', e); console.error('System info error:', e);
res.status(500).json({ error: e.message }); res.status(500).json({ error: e.message });