Compare commits
2
Commits
3ecf87146a
...
f31b8deea2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f31b8deea2 | ||
|
|
4d3df1de37 |
@@ -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
|
||||
|
||||
@@ -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 не отвечает).
|
||||
|
||||
### Команды
|
||||
|
||||
|
||||
@@ -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')), {
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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