From f31b8deea2bbdc1386f442fc51e6f9de2fae8673 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 27 Sep 2026 12:46:49 +0300 Subject: [PATCH] =?UTF-8?q?feat(settings):=20=D0=B1=D0=BB=D0=BE=D0=BA=20?= =?UTF-8?q?=C2=AB=D0=A1=D1=82=D0=B0=D1=82=D1=83=D1=81=20=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D0=B0=C2=BB=20=E2=80=94=20=D0=BD=D0=B0=20=D1=87=D1=91?= =?UTF-8?q?=D0=BC=20=D0=B2=D1=81=D1=91=20=D0=BA=D1=80=D1=83=D1=82=D0=B8?= =?UTF-8?q?=D1=82=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На странице Настроек появился блок с состоянием стека системы: версии компонентов, состояние сервисов и нагрузка. Рендерится из нового блока 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 --- AGENTS.md | 9 +++ README.md | 5 +- api.smoketest.js | 4 ++ public/admin.css | 1 + public/js/settings.js | 164 +++++++++++++++++++++++++++++++++++++++--- public/settings.html | 14 ++++ server.js | 138 ++++++++++++++++++++++++++++++++++- 7 files changed, 323 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7f948e5..0a5a7bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 2a19bd8..c864994 100644 --- a/README.md +++ b/README.md @@ -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..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 не отвечает). ### Команды diff --git a/api.smoketest.js b/api.smoketest.js index 2085ea9..6b1d433 100644 --- a/api.smoketest.js +++ b/api.smoketest.js @@ -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')), { diff --git a/public/admin.css b/public/admin.css index 3e4a87f..a35500a 100644 --- a/public/admin.css +++ b/public/admin.css @@ -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} diff --git a/public/js/settings.js b/public/js/settings.js index 2e9e0fe..ee85e1f 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -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 = '
Ошибка загрузки
'; + const st = document.getElementById('stackInfo'); + if (st) st.innerHTML = '
Ошибка загрузки
'; } catch { el.innerHTML = '
Ошибка сети
'; + const st = document.getElementById('stackInfo'); + if (st) st.innerHTML = '
Ошибка сети
'; + } 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 `${esc(text)}`; +} + +function stackBar(pct, color) { + const p = Math.min(100, Math.max(0, Math.round(pct || 0))); + return `
`; +} + +function stackRow(label, value) { + if (value === null || value === undefined || value === '') return ''; + return `
${esc(label)}${value}
`; +} + +function renderStackInfo(stack) { + const el = document.getElementById('stackInfo'); + if (!el) return; + if (!stack) { + el.innerHTML = '
Стек недоступен
'; + 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 + ? '
Библиотеки
' + + depsList.map(([name, v]) => `
${esc(name)}${esc(v)}
`).join('') + + '
' + : ''; + + const appTitle = [currentSystemName || app.name, app.version].filter(Boolean).join(' '); + const build = app.commit + ? `${esc(appTitle)} · ${esc(app.commit)}` + : 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 = ` +
+
Приложение
+ ${stackRow('Версия', build)} + ${stackRow('Node.js', esc(app.node || '—'))} + ${stackRow('Процесс', esc(`PID ${app.pid} · ${app.rss || '—'}`))} + ${stackRow('Аптайм', esc(fmtDuration(app.uptime_s)))} +
Куча: ${esc(app.heap_used || '—')}${app.name ? ` · пакет ${esc(app.name)}` : ''}
+ ${depsHtml} +
+ +
+
Сервер
+ ${stackRow('ОС', esc(rt.os || '—'))} + ${stackRow('Ядро', esc(rt.kernel || '—'))} + ${stackRow('Архитектура', esc(`${rt.arch || '—'} · ${cpus || '—'} ядер`))} + ${rt.cpu_model ? `
${esc(rt.cpu_model)}
` : ''} + ${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('без контейнера'))} +
+ +
+
База данных
+ ${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}` : ''}`))} +
+ +
+
Кэш
+ ${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)))} +
+ +
+
Хранилище
+ ${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 ? 'хранятся' : 'удаляются')) : ''} +
+ `; +} + 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 () => { diff --git a/public/settings.html b/public/settings.html index 3923769..ef68770 100644 --- a/public/settings.html +++ b/public/settings.html @@ -20,6 +20,7 @@ +
+
+
+

Статус стека

+ + +
+

На чём работает приложение прямо сейчас: версии компонентов, состояние сервисов и нагрузка. Обновление перезагружает данные и этого блока, и системной информации выше.

+
+
Загрузка…
+
+
+
diff --git a/server.js b/server.js index 8a77e92..d801f50 100644 --- a/server.js +++ b/server.js @@ -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 });