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:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user