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:
+28
-2
@@ -128,7 +128,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.group-card .name-input:focus{border-color:var(--accent)}
|
.group-card .name-input:focus{border-color:var(--accent)}
|
||||||
.group-card .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.2rem;padding:4px;flex-shrink:0}
|
.group-card .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.2rem;padding:4px;flex-shrink:0}
|
||||||
.group-card .del:hover{color:#ef4444}
|
.group-card .del:hover{color:#ef4444}
|
||||||
.group-card .group-schedule{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;align-items:start}
|
.group-card .group-schedule{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:8px;align-items:start}
|
||||||
.group-card .group-schedule label{font-size:.75rem;color:var(--muted);display:block;margin-bottom:4px}
|
.group-card .group-schedule label{font-size:.75rem;color:var(--muted);display:block;margin-bottom:4px}
|
||||||
.group-card .group-schedule select,.group-card .group-schedule input[type=time]{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem}
|
.group-card .group-schedule select,.group-card .group-schedule input[type=time]{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem}
|
||||||
.group-card .group-foot{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
.group-card .group-foot{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||||
@@ -144,7 +144,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.settings-card .btn-link:hover{opacity:.85;border-color:transparent}
|
.settings-card .btn-link:hover{opacity:.85;border-color:transparent}
|
||||||
.group-card .sched-status{font-size:.8rem;color:var(--muted)}
|
.group-card .sched-status{font-size:.8rem;color:var(--muted)}
|
||||||
@media(max-width:1024px){.group-grid{grid-template-columns:repeat(2,1fr)}}
|
@media(max-width:1024px){.group-grid{grid-template-columns:repeat(2,1fr)}}
|
||||||
@media(max-width:640px){.group-grid{grid-template-columns:1fr}}
|
@media(max-width:640px){
|
||||||
|
.group-grid{grid-template-columns:1fr}
|
||||||
|
.group-card .group-schedule{grid-template-columns:1fr 1fr}
|
||||||
|
}
|
||||||
|
@media(max-width:480px){
|
||||||
|
.group-card .group-schedule{grid-template-columns:1fr}
|
||||||
|
}
|
||||||
|
|
||||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;display:none;align-items:center;justify-content:center}
|
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;display:none;align-items:center;justify-content:center}
|
||||||
.modal-overlay.open{display:flex}
|
.modal-overlay.open{display:flex}
|
||||||
@@ -297,6 +303,26 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.stat .label a:hover{text-decoration:underline}
|
.stat .label a:hover{text-decoration:underline}
|
||||||
@media(max-width:1100px){.dash-grid{grid-template-columns:1fr}}
|
@media(max-width:1100px){.dash-grid{grid-template-columns:1fr}}
|
||||||
@media(max-width:640px){.photos-grid-dash{grid-template-columns:repeat(2,1fr)}}
|
@media(max-width:640px){.photos-grid-dash{grid-template-columns:repeat(2,1fr)}}
|
||||||
|
/* --- System Info Dashboard --- */
|
||||||
|
.sys-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}
|
||||||
|
@media(max-width:480px){.sys-grid{grid-template-columns:1fr;gap:10px}}
|
||||||
|
.sys-card{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:8px;min-width:0}
|
||||||
|
.sys-card.loading{color:var(--muted);justify-content:center;align-items:center;min-height:80px}
|
||||||
|
.sys-card.err{color:#ef4444}
|
||||||
|
.sys-title{font-size:.75rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||||
|
.sys-item{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:4px 0}
|
||||||
|
.sys-label{font-size:.82rem;color:var(--muted)}
|
||||||
|
.sys-value{font-size:.9rem;font-weight:600;color:var(--text);text-align:right;word-break:break-word}
|
||||||
|
.sys-details{margin-top:8px;padding-top:8px;border-top:1px solid var(--border)}
|
||||||
|
.sys-details summary{cursor:pointer;font-size:.75rem;color:var(--muted);font-weight:600}
|
||||||
|
.sys-table-list{margin-top:8px;display:flex;flex-direction:column;gap:4px}
|
||||||
|
.sys-table-row{display:flex;justify-content:space-between;font-size:.75rem;padding:2px 0}
|
||||||
|
.sys-table-row span:first-child{color:var(--muted)}
|
||||||
|
.sys-table-row span:last-child{font-weight:600;color:var(--text)}
|
||||||
|
.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}
|
||||||
|
|
||||||
.actions-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
.actions-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||||
.audit-table{width:100%;border-collapse:collapse;font-size:.85rem}
|
.audit-table{width:100%;border-collapse:collapse;font-size:.85rem}
|
||||||
.audit-table th{text-align:left;color:var(--muted);font-weight:600;padding:8px 10px;border-bottom:1px solid var(--border);font-size:.75rem;text-transform:uppercase;letter-spacing:.03em}
|
.audit-table th{text-align:left;color:var(--muted);font-weight:600;padding:8px 10px;border-bottom:1px solid var(--border);font-size:.75rem;text-transform:uppercase;letter-spacing:.03em}
|
||||||
|
|||||||
+50
-9
@@ -11,8 +11,9 @@
|
|||||||
<div class="sidebar" id="sidebar"></div>
|
<div class="sidebar" id="sidebar"></div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<h2>Группы</h2>
|
<h2>Группы</h2>
|
||||||
<div class="add-row">
|
<div class="add-row" style="align-items:center;gap:8px">
|
||||||
<input type="text" id="newGroup" placeholder="Название группы">
|
<input type="text" id="newGroup" placeholder="Название группы" style="flex:1;max-width:300px">
|
||||||
|
<select id="newGroupBranch" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;min-width:180px"><option value="">— без филиала —</option></select>
|
||||||
<button onclick="addGroup()">Добавить</button>
|
<button onclick="addGroup()">Добавить</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="list" id="groupList"></div>
|
<div class="list" id="groupList"></div>
|
||||||
@@ -62,8 +63,20 @@
|
|||||||
<script>
|
<script>
|
||||||
// --- Groups ---
|
// --- Groups ---
|
||||||
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||||||
|
let branchesCache = [];
|
||||||
|
|
||||||
|
async function loadBranchesCache() {
|
||||||
|
const res = await fetch(`${API}/api/branches`, { headers: hdr() });
|
||||||
|
if (res.ok) branchesCache = await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchSelectHTML(selectedId) {
|
||||||
|
const opts = branchesCache.map(b => `<option value="${b.id}" ${b.id === selectedId ? 'selected' : ''}>${esc(b.name)}</option>`).join('');
|
||||||
|
return `<div><label>Филиал</label><select class="branch-select" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem"><option value="">— без филиала —</option>${opts}</select></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadGroups() {
|
async function loadGroups() {
|
||||||
|
await loadBranchesCache();
|
||||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||||
const groups = await res.json();
|
const groups = await res.json();
|
||||||
const list = document.getElementById('groupList');
|
const list = document.getElementById('groupList');
|
||||||
@@ -73,13 +86,14 @@ async function loadGroups() {
|
|||||||
return `
|
return `
|
||||||
<div class="group-card">
|
<div class="group-card">
|
||||||
${g.cover_path
|
${g.cover_path
|
||||||
? `<img class="group-cover" src="${API}${g.cover_path}" onclick="openPhotos(${g.id}, '${esc(g.name).replace(/'/g, ''')}')" title="Фото группы (хронология)" alt="">`
|
? `<img class="group-cover" src="${API}${g.cover_path}" data-group-id="${g.id}" data-group-name="${esc(g.name).replace(/"/g, '"')}" title="Фото группы (хронология)" alt="">`
|
||||||
: `<div class="group-cover empty-cover" onclick="openPhotos(${g.id}, '${esc(g.name).replace(/'/g, ''')}')" title="Фото группы (хронология)">📷</div>`}
|
: `<div class="group-cover empty-cover" data-group-id="${g.id}" data-group-name="${esc(g.name).replace(/"/g, '"')}" title="Фото группы (хронология)">📷</div>`}
|
||||||
<div class="group-head">
|
<div class="group-head">
|
||||||
<input class="name-input" id="name-${g.id}" value="${esc(g.name)}">
|
<input class="name-input" id="name-${g.id}" value="${esc(g.name)}">
|
||||||
<button class="del" onclick="delGroup(${g.id})" title="Удалить">×</button>
|
<button class="del" onclick="delGroup(${g.id})" title="Удалить">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="group-schedule">
|
<div class="group-schedule">
|
||||||
|
${branchSelectHTML(g.branch_id)}
|
||||||
<div><label>День</label>
|
<div><label>День</label>
|
||||||
<select id="dow-${g.id}">
|
<select id="dow-${g.id}">
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
@@ -96,9 +110,9 @@ async function loadGroups() {
|
|||||||
<div class="group-foot">
|
<div class="group-foot">
|
||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
<button class="save-sched" onclick="saveGroup(${g.id})">Сохранить</button>
|
<button class="save-sched" onclick="saveGroup(${g.id})">Сохранить</button>
|
||||||
<button class="photo-btn" onclick="openPhotos(${g.id}, '${esc(g.name).replace(/'/g, ''')}')" title="Фото группы (хронология)">Фото</button>
|
<button class="photo-btn" data-group-id="${g.id}" data-group-name="${esc(g.name).replace(/"/g, '"')}" title="Фото группы (хронология)">Фото</button>
|
||||||
<button class="btn-link" onclick="exportGroup(${g.id}, '${esc(g.name).replace(/'/g, ''')}')" title="Экспортировать записи группы">CSV</button>
|
<button class="btn-link" onclick="exportGroup(${g.id}, \`${esc(g.name).replace(/'/g, '')}\`)" title="Экспортировать записи группы">CSV</button>
|
||||||
<button class="btn-link" onclick="exportGroupUrls(${g.id}, '${esc(g.name).replace(/'/g, ''')}')" title="Скачать .url файлы учеников группы">URL</button>
|
<button class="btn-link" onclick="exportGroupUrls(${g.id}, \`${esc(g.name).replace(/'/g, '')}\`)" title="Скачать .url файлы учеников группы">URL</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div>
|
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,13 +123,17 @@ async function loadGroups() {
|
|||||||
async function saveGroup(id) {
|
async function saveGroup(id) {
|
||||||
const name = document.getElementById(`name-${id}`).value.trim();
|
const name = document.getElementById(`name-${id}`).value.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
const nameInput = document.getElementById(`name-${id}`);
|
||||||
|
const groupCard = nameInput.closest('.group-card');
|
||||||
|
const branchSelect = groupCard ? groupCard.querySelector('.branch-select') : null;
|
||||||
|
const branch_id = branchSelect ? branchSelect.value : '';
|
||||||
const dow = document.getElementById(`dow-${id}`).value;
|
const dow = document.getElementById(`dow-${id}`).value;
|
||||||
const ts = document.getElementById(`ts-${id}`).value;
|
const ts = document.getElementById(`ts-${id}`).value;
|
||||||
const te = document.getElementById(`te-${id}`).value;
|
const te = document.getElementById(`te-${id}`).value;
|
||||||
const res = await fetch(`${API}/api/groups/${id}`, {
|
const res = await fetch(`${API}/api/groups/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: hdrJson(),
|
headers: hdrJson(),
|
||||||
body: JSON.stringify({ name, day_of_week: dow !== '' ? parseInt(dow) : null, time_start: ts || null, time_end: te || null })
|
body: JSON.stringify({ name, branch_id: branch_id || null, day_of_week: dow !== '' ? parseInt(dow) : null, time_start: ts || null, time_end: te || null })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
loadGroups();
|
loadGroups();
|
||||||
@@ -129,7 +147,10 @@ async function addGroup() {
|
|||||||
const input = document.getElementById('newGroup');
|
const input = document.getElementById('newGroup');
|
||||||
const name = input.value.trim();
|
const name = input.value.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name }) });
|
await loadBranchesCache();
|
||||||
|
const branchSelect = document.getElementById('newGroupBranch');
|
||||||
|
const branch_id = branchSelect ? branchSelect.value : '';
|
||||||
|
const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, branch_id: branch_id || null }) });
|
||||||
if (res.ok) { input.value = ''; loadGroups(); }
|
if (res.ok) { input.value = ''; loadGroups(); }
|
||||||
else { const e = await res.json(); alert(e.error); }
|
else { const e = await res.json(); alert(e.error); }
|
||||||
}
|
}
|
||||||
@@ -315,9 +336,29 @@ async function exportGroupUrls(groupId, groupName) {
|
|||||||
document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); });
|
document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); });
|
||||||
document.getElementById('photoFile').addEventListener('change', previewPhotoFile);
|
document.getElementById('photoFile').addEventListener('change', previewPhotoFile);
|
||||||
|
|
||||||
|
// Event delegation for group cover clicks
|
||||||
|
document.getElementById('groupList').addEventListener('click', e => {
|
||||||
|
const target = e.target.closest('[data-group-id]');
|
||||||
|
if (target) {
|
||||||
|
const id = parseInt(target.dataset.groupId, 10);
|
||||||
|
const name = target.dataset.groupName;
|
||||||
|
if (!isNaN(id)) openPhotos(id, name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function populateNewGroupBranch() {
|
||||||
|
const res = await fetch(`${API}/api/branches`, { headers: hdr() });
|
||||||
|
if (res.ok) {
|
||||||
|
const branches = await res.json();
|
||||||
|
const sel = document.getElementById('newGroupBranch');
|
||||||
|
sel.innerHTML = '<option value="">— без филиала —</option>' + branches.map(b => `<option value="${b.id}">${esc(b.name)}</option>`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
if (await checkAuth()) {
|
if (await checkAuth()) {
|
||||||
buildSidebar(document.body.dataset.page);
|
buildSidebar(document.body.dataset.page);
|
||||||
|
await populateNewGroupBranch();
|
||||||
loadGroups();
|
loadGroups();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -65,6 +65,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="restore-status" id="restoreStatus"></div>
|
<div class="restore-status" id="restoreStatus"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic">📊</div>
|
||||||
|
<h3>Системная информация</h3>
|
||||||
|
</div>
|
||||||
|
<div id="systemInfo" class="sys-grid">
|
||||||
|
<div class="sys-card loading">Загрузка…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,6 +142,72 @@ async function loadSettings() {
|
|||||||
} else {
|
} else {
|
||||||
document.getElementById('dataStats').innerHTML = '';
|
document.getElementById('dataStats').innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const siRes = await fetch(`${API}/api/system-info`, { headers: hdr() });
|
||||||
|
if (siRes.ok) {
|
||||||
|
const si = await siRes.json();
|
||||||
|
renderSystemInfo(si);
|
||||||
|
} else {
|
||||||
|
document.getElementById('systemInfo').innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSystemInfo(si) {
|
||||||
|
const db = si.database || {};
|
||||||
|
const photos = si.photos || {};
|
||||||
|
const files = si.files || {};
|
||||||
|
const uploads = si.uploads || {};
|
||||||
|
const disk = si.disk;
|
||||||
|
|
||||||
|
let tablesHtml = '';
|
||||||
|
if (db.tables && db.tables.length) {
|
||||||
|
tablesHtml = '<details class="sys-details"><summary>Таблицы БД</summary><div class="sys-table-list">' +
|
||||||
|
db.tables.map(t => `<div class="sys-table-row"><span>${t.name}</span><span>${t.size}</span></div>`).join('') +
|
||||||
|
'</div></details>';
|
||||||
|
}
|
||||||
|
|
||||||
|
let diskHtml = '';
|
||||||
|
if (disk) {
|
||||||
|
const pct = disk.used_pct;
|
||||||
|
const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#16a34a';
|
||||||
|
diskHtml = `
|
||||||
|
<div class="sys-item">
|
||||||
|
<span class="sys-label">Диск</span>
|
||||||
|
<span class="sys-value">${disk.used} / ${disk.total}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sys-bar-wrap">
|
||||||
|
<div class="sys-bar" style="width:${pct}%; background:${color}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sys-meta">Свободно: ${disk.free} (${100 - pct}%)</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('systemInfo').innerHTML = `
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">База данных</div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${db.size || '—'}</span></div>
|
||||||
|
${tablesHtml}
|
||||||
|
</div>
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">Фото</div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Фото групп</span><span class="sys-value">${photos.group_photos?.count || 0}</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Фото в записях</span><span class="sys-value">${photos.entry_photos?.count || 0}</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Всего</span><span class="sys-value"><b>${photos.total_count || 0}</b></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">Файлы</div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Проектные файлы</span><span class="sys-value">${files.project_files?.count || 0}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">Загрузки</div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Файлов</span><span class="sys-value">${uploads.count || 0}</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${uploads.size || '0 B'}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">Диск</div>
|
||||||
|
${diskHtml || '<div class="sys-meta">Недоступно</div>'}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
|
|||||||
@@ -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) {
|
async function getSetting(key, def) {
|
||||||
const { rows } = await pool.query('SELECT value FROM settings WHERE key = $1', [key]);
|
const { rows } = await pool.query('SELECT value FROM settings WHERE key = $1', [key]);
|
||||||
return rows.length ? rows[0].value : def;
|
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)');
|
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) {
|
async function logAudit(req, action, target) {
|
||||||
try {
|
try {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
@@ -760,13 +795,14 @@ app.get('/s/:token', (req, res) => {
|
|||||||
// --- Groups CRUD ---
|
// --- Groups CRUD ---
|
||||||
app.get('/api/groups', apiLimiter, async (_, res) => {
|
app.get('/api/groups', apiLimiter, async (_, res) => {
|
||||||
const { rows } = await pool.query(
|
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
|
FROM groups g
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT photo_path FROM group_photos
|
SELECT photo_path FROM group_photos
|
||||||
WHERE group_id = g.id
|
WHERE group_id = g.id
|
||||||
ORDER BY taken_at DESC NULLS LAST, created_at DESC LIMIT 1
|
ORDER BY taken_at DESC NULLS LAST, created_at DESC LIMIT 1
|
||||||
) gp ON true
|
) gp ON true
|
||||||
|
LEFT JOIN branches b ON b.id = g.branch_id
|
||||||
ORDER BY g.id`
|
ORDER BY g.id`
|
||||||
);
|
);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
@@ -786,18 +822,21 @@ app.get('/api/groups/active', apiLimiter, async (_, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/groups/:id', requireAdmin, async (req, 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 {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`UPDATE groups SET
|
`UPDATE groups SET
|
||||||
name = COALESCE($1, name),
|
name = COALESCE($1, name),
|
||||||
day_of_week = $2,
|
day_of_week = $2,
|
||||||
time_start = $3,
|
time_start = $3,
|
||||||
time_end = $4
|
time_end = $4,
|
||||||
WHERE id = $5 RETURNING *`,
|
branch_id = $5
|
||||||
|
WHERE id = $6 RETURNING *`,
|
||||||
[name,
|
[name,
|
||||||
day_of_week === undefined || day_of_week === null || day_of_week === '' ? null : day_of_week,
|
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 });
|
await logAudit(req, 'group.update', { id: req.params.id, ...req.body });
|
||||||
res.json(rows[0]);
|
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) => {
|
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' });
|
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
'INSERT INTO groups (name) VALUES ($1) RETURNING *',
|
'INSERT INTO groups (name, branch_id) VALUES ($1, $2) RETURNING *',
|
||||||
[name.trim()]
|
[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]);
|
res.status(201).json(rows[0]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' });
|
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 });
|
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) => {
|
app.get('/api/groups/:id/photos', requireAdmin, async (req, res) => {
|
||||||
const { limit, offset } = req.query;
|
const { limit, offset } = req.query;
|
||||||
const { rows: crows } = await pool.query(
|
const { rows: crows } = await pool.query(
|
||||||
@@ -1034,6 +1130,53 @@ app.get('/api/entries/:id/files', requireAdmin, async (req, res) => {
|
|||||||
res.json(rows);
|
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) {
|
function isImageName(name) {
|
||||||
return /\.(jpe?g|png|gif|webp|bmp|avif|ico)$/i.test(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) => {
|
app.get('/api/dashboard', requireAdmin, async (req, res) => {
|
||||||
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||||||
const [stats, activity, active, recent, top, photos, latestPhotos] = await Promise.all([
|
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));
|
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;
|
const HTTPS_PORT = process.env.HTTPS_PORT || 3443;
|
||||||
|
|
||||||
process.on('unhandledRejection', (err) => { console.error('Unhandled rejection:', err); });
|
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 certPath = path.join(__dirname, 'certs', 'cert.pem');
|
||||||
const keyPath = path.join(__dirname, 'certs', 'key.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)) {
|
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||||
https.createServer({ key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) }, app)
|
const httpsServer = https.createServer({ key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) }, app);
|
||||||
.listen(HTTPS_PORT, '0.0.0.0', () => console.log(`HTTPS : ${HTTPS_PORT}`));
|
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 {
|
} 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));
|
sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err));
|
||||||
ensureAuditTable().catch(err => console.error('Audit table:', err));
|
ensureAuditTable().catch(err => console.error('Audit table:', err));
|
||||||
|
ensureBranchesTable().catch(err => console.error('Branches table:', err));
|
||||||
|
|||||||
Reference in New Issue
Block a user