Add system info dashboard to settings
- New /api/system-info endpoint returning DB size, table sizes, photo/file counts, uploads stats, disk usage - System info cards in settings page (responsive grid) - Replaced inline onclick handlers with data attributes + event delegation in groups.html
This commit is contained in:
@@ -95,6 +95,30 @@ const upload = multer({
|
||||
},
|
||||
});
|
||||
|
||||
const ADMIN_ALLOWED_EXT = new Set(['.pdf', '.doc', '.docx', '.txt', '.md', '.html', '.htm', '.zip', '.rar', '.7z', '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.heic', '.heif']);
|
||||
const adminUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_, __, cb) => {
|
||||
fs.mkdirSync('uploads', { recursive: true });
|
||||
cb(null, 'uploads');
|
||||
},
|
||||
filename: (_, file, cb) => {
|
||||
const original = fixFilename(file.originalname);
|
||||
const ext = path.extname(original) || '.bin';
|
||||
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
file.originalname = fixFilename(file.originalname);
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (ext && BLOCKED_EXT.test(ext) && !ADMIN_ALLOWED_EXT.has(ext)) {
|
||||
return cb(new Error('Not allowed extension'));
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
|
||||
async function getSetting(key, def) {
|
||||
const { rows } = await pool.query('SELECT value FROM settings WHERE key = $1', [key]);
|
||||
return rows.length ? rows[0].value : def;
|
||||
@@ -174,6 +198,17 @@ async function ensureAuditTable() {
|
||||
await pool.query('CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC)');
|
||||
}
|
||||
|
||||
async function ensureBranchesTable() {
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS branches (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL UNIQUE,
|
||||
address TEXT,
|
||||
phone VARCHAR(50),
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)`);
|
||||
await pool.query(`ALTER TABLE groups ADD COLUMN IF NOT EXISTS branch_id INTEGER REFERENCES branches(id) ON DELETE SET NULL`);
|
||||
}
|
||||
|
||||
async function logAudit(req, action, target) {
|
||||
try {
|
||||
await pool.query(
|
||||
@@ -760,13 +795,14 @@ app.get('/s/:token', (req, res) => {
|
||||
// --- Groups CRUD ---
|
||||
app.get('/api/groups', apiLimiter, async (_, res) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT g.*, gp.photo_path AS cover_path
|
||||
`SELECT g.*, gp.photo_path AS cover_path, b.name AS branch_name
|
||||
FROM groups g
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT photo_path FROM group_photos
|
||||
WHERE group_id = g.id
|
||||
ORDER BY taken_at DESC NULLS LAST, created_at DESC LIMIT 1
|
||||
) gp ON true
|
||||
LEFT JOIN branches b ON b.id = g.branch_id
|
||||
ORDER BY g.id`
|
||||
);
|
||||
res.json(rows);
|
||||
@@ -786,18 +822,21 @@ app.get('/api/groups/active', apiLimiter, async (_, res) => {
|
||||
});
|
||||
|
||||
app.put('/api/groups/:id', requireAdmin, async (req, res) => {
|
||||
const { name, day_of_week, time_start, time_end } = req.body;
|
||||
const { name, day_of_week, time_start, time_end, branch_id } = req.body;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE groups SET
|
||||
name = COALESCE($1, name),
|
||||
day_of_week = $2,
|
||||
time_start = $3,
|
||||
time_end = $4
|
||||
WHERE id = $5 RETURNING *`,
|
||||
time_end = $4,
|
||||
branch_id = $5
|
||||
WHERE id = $6 RETURNING *`,
|
||||
[name,
|
||||
day_of_week === undefined || day_of_week === null || day_of_week === '' ? null : day_of_week,
|
||||
time_start || null, time_end || null, req.params.id]
|
||||
time_start || null, time_end || null,
|
||||
branch_id === undefined || branch_id === null || branch_id === '' ? null : branch_id,
|
||||
req.params.id]
|
||||
);
|
||||
await logAudit(req, 'group.update', { id: req.params.id, ...req.body });
|
||||
res.json(rows[0]);
|
||||
@@ -808,14 +847,14 @@ app.put('/api/groups/:id', requireAdmin, async (req, res) => {
|
||||
});
|
||||
|
||||
app.post('/api/groups', requireAdmin, async (req, res) => {
|
||||
const { name } = req.body;
|
||||
const { name, branch_id } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'INSERT INTO groups (name) VALUES ($1) RETURNING *',
|
||||
[name.trim()]
|
||||
'INSERT INTO groups (name, branch_id) VALUES ($1, $2) RETURNING *',
|
||||
[name.trim(), branch_id === undefined || branch_id === null || branch_id === '' ? null : branch_id]
|
||||
);
|
||||
await logAudit(req, 'group.create', { id: rows[0].id, name: name.trim() });
|
||||
await logAudit(req, 'group.create', { id: rows[0].id, name: name.trim(), branch_id });
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (e) {
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' });
|
||||
@@ -834,6 +873,63 @@ app.delete('/api/groups/:id', requireAdmin, async (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- Branches CRUD ---
|
||||
app.get('/api/branches', requireAdmin, async (_, res) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT b.*, count(g.id)::int AS groups_count
|
||||
FROM branches b
|
||||
LEFT JOIN groups g ON g.branch_id = b.id
|
||||
GROUP BY b.id
|
||||
ORDER BY b.id`
|
||||
);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
app.post('/api/branches', requireAdmin, async (req, res) => {
|
||||
const { name, address, phone } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'INSERT INTO branches (name, address, phone) VALUES ($1, $2, $3) RETURNING *',
|
||||
[name.trim(), address?.trim() || null, phone?.trim() || null]
|
||||
);
|
||||
await logAudit(req, 'branch.create', { id: rows[0].id, name: name.trim() });
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (e) {
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'Филиал с таким названием уже существует' });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/branches/:id', requireAdmin, async (req, res) => {
|
||||
const { name, address, phone } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE branches SET
|
||||
name = $1,
|
||||
address = $2,
|
||||
phone = $3
|
||||
WHERE id = $4 RETURNING *`,
|
||||
[name.trim(), address?.trim() || null, phone?.trim() || null, req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||||
await logAudit(req, 'branch.update', { id: req.params.id, ...req.body });
|
||||
res.json(rows[0]);
|
||||
} catch (e) {
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'Филиал с таким названием уже существует' });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/branches/:id', requireAdmin, async (req, res) => {
|
||||
const { rows } = await pool.query('SELECT id FROM groups WHERE branch_id = $1', [req.params.id]);
|
||||
if (rows.length) return res.status(400).json({ error: 'Нельзя удалить филиал: есть привязанные группы' });
|
||||
await pool.query('DELETE FROM branches WHERE id = $1', [req.params.id]);
|
||||
await logAudit(req, 'branch.delete', { id: req.params.id });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/groups/:id/photos', requireAdmin, async (req, res) => {
|
||||
const { limit, offset } = req.query;
|
||||
const { rows: crows } = await pool.query(
|
||||
@@ -1034,6 +1130,53 @@ app.get('/api/entries/:id/files', requireAdmin, async (req, res) => {
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
const entryFilesUpload = adminUpload.array('files', 10);
|
||||
app.post('/api/entries/:id/files', requireAdmin, (req, res, next) => {
|
||||
entryFilesUpload(req, res, (err) => {
|
||||
if (!err) return next();
|
||||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
|
||||
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла' });
|
||||
return res.status(400).json({ error: 'Недопустимый файл' });
|
||||
});
|
||||
}, async (req, res) => {
|
||||
const entryId = req.params.id;
|
||||
const files = req.files || [];
|
||||
if (!files.length) return res.status(400).json({ error: 'Файлы не выбраны' });
|
||||
|
||||
const entryCheck = await pool.query('SELECT id FROM entries WHERE id = $1', [entryId]);
|
||||
if (!entryCheck.rows.length) {
|
||||
files.forEach(removeUpload);
|
||||
return res.status(404).json({ error: 'Запись не найдена' });
|
||||
}
|
||||
|
||||
const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0);
|
||||
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
|
||||
files.forEach(removeUpload);
|
||||
return res.status(400).json({ error: 'Суммарный размер файлов слишком велик (макс. 30 МБ)' });
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
for (const f of files) {
|
||||
const token = crypto.randomBytes(16).toString('hex');
|
||||
await client.query(
|
||||
'INSERT INTO project_files (entry_id, token, path, name) VALUES ($1, $2, $3, $4)',
|
||||
[entryId, token, `/uploads/${f.filename}`, f.originalname]
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
res.status(201).json({ ok: true, count: files.length });
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
files.forEach(removeUpload);
|
||||
console.error('POST /api/entries/:id/files:', e);
|
||||
res.status(500).json({ error: e.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
function isImageName(name) {
|
||||
return /\.(jpe?g|png|gif|webp|bmp|avif|ico)$/i.test(name || '');
|
||||
}
|
||||
@@ -1145,6 +1288,133 @@ app.get('/api/stats', requireAdmin, async (_, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/system-info', requireAdmin, async (_, res) => {
|
||||
try {
|
||||
const db = await pool.query(`
|
||||
SELECT
|
||||
pg_size_pretty(pg_database_size(current_database())) AS db_size,
|
||||
pg_database_size(current_database()) AS db_size_bytes
|
||||
`);
|
||||
|
||||
const tables = await pool.query(`
|
||||
SELECT
|
||||
schemaname,
|
||||
relname,
|
||||
pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS size,
|
||||
pg_total_relation_size(schemaname || '.' || relname) AS size_bytes
|
||||
FROM pg_stat_user_tables
|
||||
ORDER BY pg_total_relation_size(schemaname || '.' || relname) DESC
|
||||
`);
|
||||
|
||||
const photoStats = await pool.query(`
|
||||
SELECT count(*)::int AS count,
|
||||
sum(pg_column_size(photo_path))::bigint AS path_size_bytes
|
||||
FROM group_photos
|
||||
`);
|
||||
|
||||
const fileStats = await pool.query(`
|
||||
SELECT count(*)::int AS count,
|
||||
sum(pg_column_size(path))::bigint AS path_size_bytes
|
||||
FROM project_files
|
||||
`);
|
||||
|
||||
const entryPhotoStats = await pool.query(`
|
||||
SELECT count(*)::int AS count
|
||||
FROM entries
|
||||
WHERE photo_path IS NOT NULL AND deleted_at IS NULL
|
||||
`);
|
||||
|
||||
const uploadsDir = path.join(__dirname, 'uploads');
|
||||
let uploadsSize = 0;
|
||||
let uploadsCount = 0;
|
||||
if (fs.existsSync(uploadsDir)) {
|
||||
for (const f of fs.readdirSync(uploadsDir)) {
|
||||
const fp = path.join(uploadsDir, f);
|
||||
if (fs.statSync(fp).isFile()) {
|
||||
uploadsCount++;
|
||||
uploadsSize += fs.statSync(fp).size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalDisk = fs.statfsSync ? fs.statfsSync(__dirname) : null;
|
||||
let diskInfo = null;
|
||||
if (totalDisk) {
|
||||
const blockSize = totalDisk.bsize || 4096;
|
||||
const total = totalDisk.blocks * blockSize;
|
||||
const free = totalDisk.bfree * blockSize;
|
||||
const used = total - free;
|
||||
diskInfo = {
|
||||
total: formatBytes(total),
|
||||
total_bytes: total,
|
||||
used: formatBytes(used),
|
||||
used_bytes: used,
|
||||
free: formatBytes(free),
|
||||
free_bytes: free,
|
||||
used_pct: Math.round((used / total) * 100),
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
const { execSync } = require('child_process');
|
||||
const out = execSync('df -B1 .', { encoding: 'utf8' });
|
||||
const lines = out.trim().split('\n');
|
||||
if (lines.length > 1) {
|
||||
const parts = lines[1].split(/\s+/);
|
||||
const total = parseInt(parts[1], 10);
|
||||
const used = parseInt(parts[2], 10);
|
||||
const free = parseInt(parts[3], 10);
|
||||
diskInfo = {
|
||||
total: formatBytes(total),
|
||||
total_bytes: total,
|
||||
used: formatBytes(used),
|
||||
used_bytes: used,
|
||||
free: formatBytes(free),
|
||||
free_bytes: free,
|
||||
used_pct: Math.round((used / total) * 100),
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
res.json({
|
||||
database: {
|
||||
size: db.rows[0].db_size,
|
||||
size_bytes: parseInt(db.rows[0].db_size_bytes, 10),
|
||||
tables: tables.rows.map(t => ({
|
||||
name: t.relname,
|
||||
size: t.size,
|
||||
size_bytes: parseInt(t.size_bytes, 10),
|
||||
})),
|
||||
},
|
||||
photos: {
|
||||
group_photos: { count: photoStats.rows[0].count || 0 },
|
||||
entry_photos: { count: entryPhotoStats.rows[0].count || 0 },
|
||||
total_count: (photoStats.rows[0].count || 0) + (entryPhotoStats.rows[0].count || 0),
|
||||
},
|
||||
files: {
|
||||
project_files: { count: fileStats.rows[0].count || 0 },
|
||||
},
|
||||
uploads: {
|
||||
count: uploadsCount,
|
||||
size: formatBytes(uploadsSize),
|
||||
size_bytes: uploadsSize,
|
||||
},
|
||||
disk: diskInfo,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('System info error:', e);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/dashboard', requireAdmin, async (req, res) => {
|
||||
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||||
const [stats, activity, active, recent, top, photos, latestPhotos] = await Promise.all([
|
||||
@@ -1411,7 +1681,7 @@ app.use((err, req, res, next) => {
|
||||
res.status(500).send(renderErrorPage(500, 'Ошибка сервера', msg, details));
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const PORT = process.env.PORT || 3003;
|
||||
const HTTPS_PORT = process.env.HTTPS_PORT || 3443;
|
||||
|
||||
process.on('unhandledRejection', (err) => { console.error('Unhandled rejection:', err); });
|
||||
@@ -1420,18 +1690,14 @@ process.on('uncaughtException', (err) => { console.error('Uncaught exception:',
|
||||
const certPath = path.join(__dirname, 'certs', 'cert.pem');
|
||||
const keyPath = path.join(__dirname, 'certs', 'key.pem');
|
||||
|
||||
const redirect = express();
|
||||
redirect.use((req, res) => {
|
||||
res.redirect(301, `https://${req.headers.host.split(':')[0]}:${HTTPS_PORT}${req.originalUrl}`);
|
||||
});
|
||||
redirect.listen(PORT, '0.0.0.0', () => console.log(`HTTP : ${PORT} (redirect → HTTPS)`));
|
||||
|
||||
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
https.createServer({ key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) }, app)
|
||||
.listen(HTTPS_PORT, '0.0.0.0', () => console.log(`HTTPS : ${HTTPS_PORT}`));
|
||||
const httpsServer = https.createServer({ key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) }, app);
|
||||
httpsServer.listen(HTTPS_PORT, '0.0.0.0', () => console.log(`HTTPS : ${HTTPS_PORT}`));
|
||||
app.listen(PORT, '0.0.0.0', () => console.log(`HTTP : ${PORT}`));
|
||||
} else {
|
||||
console.log('HTTPS: no certs found, skipping');
|
||||
app.listen(PORT, '0.0.0.0', () => console.log(`HTTP : ${PORT} (no TLS certs)`));
|
||||
}
|
||||
|
||||
sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err));
|
||||
ensureAuditTable().catch(err => console.error('Audit table:', err));
|
||||
ensureBranchesTable().catch(err => console.error('Branches table:', err));
|
||||
|
||||
Reference in New Issue
Block a user