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:
dev
2026-09-27 12:47:04 +03:00
parent 4d3df1de37
commit f31b8deea2
7 changed files with 323 additions and 12 deletions
+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 });