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:
dev
2026-09-09 01:08:48 +03:00
parent 616dabb595
commit 7a003e5df6
4 changed files with 439 additions and 30 deletions
+28 -2
View File
@@ -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 .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 .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 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}
@@ -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}
.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: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.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}
@media(max-width:1100px){.dash-grid{grid-template-columns: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}
.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}
+50 -9
View File
@@ -11,8 +11,9 @@
<div class="sidebar" id="sidebar"></div>
<div class="main">
<h2>Группы</h2>
<div class="add-row">
<input type="text" id="newGroup" placeholder="Название группы">
<div class="add-row" style="align-items:center;gap:8px">
<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>
</div>
<div class="list" id="groupList"></div>
@@ -62,8 +63,20 @@
<script>
// --- Groups ---
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() {
await loadBranchesCache();
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
const list = document.getElementById('groupList');
@@ -73,13 +86,14 @@ async function loadGroups() {
return `
<div class="group-card">
${g.cover_path
? `<img class="group-cover" src="${API}${g.cover_path}" onclick="openPhotos(${g.id}, '${esc(g.name).replace(/'/g, '&#39;')}')" title="Фото группы (хронология)" alt="">`
: `<div class="group-cover empty-cover" onclick="openPhotos(${g.id}, '${esc(g.name).replace(/'/g, '&#39;')}')" title="Фото группы (хронология)">📷</div>`}
? `<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" data-group-id="${g.id}" data-group-name="${esc(g.name).replace(/"/g, '"')}" title="Фото группы (хронология)">📷</div>`}
<div class="group-head">
<input class="name-input" id="name-${g.id}" value="${esc(g.name)}">
<button class="del" onclick="delGroup(${g.id})" title="Удалить">&times;</button>
</div>
<div class="group-schedule">
${branchSelectHTML(g.branch_id)}
<div><label>День</label>
<select id="dow-${g.id}">
<option value="">—</option>
@@ -96,9 +110,9 @@ async function loadGroups() {
<div class="group-foot">
<div style="display:flex;gap:8px">
<button class="save-sched" onclick="saveGroup(${g.id})">Сохранить</button>
<button class="photo-btn" onclick="openPhotos(${g.id}, '${esc(g.name).replace(/'/g, '&#39;')}')" title="Фото группы (хронология)">Фото</button>
<button class="btn-link" onclick="exportGroup(${g.id}, '${esc(g.name).replace(/'/g, '&#39;')}')" title="Экспортировать записи группы">CSV</button>
<button class="btn-link" onclick="exportGroupUrls(${g.id}, '${esc(g.name).replace(/'/g, '&#39;')}')" title="Скачать .url файлы учеников группы">URL</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="exportGroupUrls(${g.id}, \`${esc(g.name).replace(/'/g, '')}\`)" title="Скачать .url файлы учеников группы">URL</button>
</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>
@@ -109,13 +123,17 @@ async function loadGroups() {
async function saveGroup(id) {
const name = document.getElementById(`name-${id}`).value.trim();
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 ts = document.getElementById(`ts-${id}`).value;
const te = document.getElementById(`te-${id}`).value;
const res = await fetch(`${API}/api/groups/${id}`, {
method: 'PUT',
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) {
loadGroups();
@@ -129,7 +147,10 @@ async function addGroup() {
const input = document.getElementById('newGroup');
const name = input.value.trim();
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(); }
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('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 () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
await populateNewGroupBranch();
loadGroups();
}
})();
+76
View File
@@ -65,6 +65,16 @@
</div>
<div class="restore-status" id="restoreStatus"></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>
@@ -132,6 +142,72 @@ async function loadSettings() {
} else {
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() {