197 lines
7.7 KiB
HTML
197 lines
7.7 KiB
HTML
<!DOCTYPE html>
|
|||
|
|
<html lang="en">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
|
|
<title>Storage · Martinhal ISDSS</title>
|
||
|
|
<link rel="stylesheet" href="/css/style.css">
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<script src="/js/app.js"></script>
|
||
|
|
<script>
|
||
|
|
(async () => {
|
||
|
|
const shell = await buildShell('storage'); if (!shell) return;
|
||
|
|
const { content } = shell;
|
||
|
|
let data = null;
|
||
|
|
let sortBy = 'total'; // 'total' | 'name'
|
||
|
|
|
||
|
|
function pct(part, whole) {
|
||
|
|
if (!whole) return 0;
|
||
|
|
return Math.round((part / whole) * 1000) / 10;
|
||
|
|
}
|
||
|
|
|
||
|
|
function barClass(p) {
|
||
|
|
if (p >= 90) return 'bar-crit';
|
||
|
|
if (p >= 75) return 'bar-warn';
|
||
|
|
return 'bar-ok';
|
||
|
|
}
|
||
|
|
|
||
|
|
function diskCard() {
|
||
|
|
const d = data.disk;
|
||
|
|
const p = d.percent_used;
|
||
|
|
return `
|
||
|
|
<div class="card">
|
||
|
|
<div class="card-head"><h3>Server disk</h3>
|
||
|
|
<span class="hint">Filesystem holding the uploads directory</span></div>
|
||
|
|
<div class="card-body">
|
||
|
|
<div class="usage-bar"><span class="${barClass(p)}" style="width:${Math.min(p, 100)}%"></span></div>
|
||
|
|
<div class="usage-legend">
|
||
|
|
<span><strong>${fmtBytes(d.used)}</strong> used</span>
|
||
|
|
<span><strong>${fmtBytes(d.free)}</strong> free</span>
|
||
|
|
<span><strong>${fmtBytes(d.total)}</strong> total</span>
|
||
|
|
<span class="usage-pct ${p >= 90 ? 'crit' : (p >= 75 ? 'warn' : '')}">${p}% used</span>
|
||
|
|
</div>
|
||
|
|
${p >= 90 ? `<div class="warn-banner" style="margin-top:14px">
|
||
|
|
<strong>The disk is nearly full.</strong> Uploads will start failing when it runs out.
|
||
|
|
</div>` : ''}
|
||
|
|
</div>
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function appCard() {
|
||
|
|
const a = data.app;
|
||
|
|
const rows = [
|
||
|
|
['Uploaded files', a.uploads_bytes, `${a.uploads_files} file${a.uploads_files === 1 ? '' : 's'}`, data.paths.uploads],
|
||
|
|
['Brand images', a.brand_bytes, `${a.brand_files} file${a.brand_files === 1 ? '' : 's'}`, data.paths.brand],
|
||
|
|
['Database', a.database_bytes, 'accounts, folders, logs', data.paths.database],
|
||
|
|
].map(([label, bytes, note, where]) => `
|
||
|
|
<tr>
|
||
|
|
<td><strong>${esc(label)}</strong><div class="hint mono">${esc(where)}</div></td>
|
||
|
|
<td class="mono">${fmtBytes(bytes)}</td>
|
||
|
|
<td class="hint">${esc(note)}</td>
|
||
|
|
<td style="width:34%">
|
||
|
|
<div class="usage-bar sm"><span class="bar-ok" style="width:${pct(bytes, a.total_bytes)}%"></span></div>
|
||
|
|
</td>
|
||
|
|
</tr>`).join('');
|
||
|
|
|
||
|
|
return `
|
||
|
|
<div class="card">
|
||
|
|
<div class="card-head"><h3>Used by ISDSS</h3>
|
||
|
|
<span class="hint">${fmtBytes(data.app.total_bytes)} in total</span></div>
|
||
|
|
<div class="card-body">
|
||
|
|
<table><tbody>${rows}</tbody></table>
|
||
|
|
</div>
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function foldersCard() {
|
||
|
|
const folders = [...data.folders];
|
||
|
|
if (sortBy === 'total') folders.sort((a, b) => b.total_bytes - a.total_bytes || a.path.localeCompare(b.path));
|
||
|
|
else folders.sort((a, b) => a.path.localeCompare(b.path));
|
||
|
|
|
||
|
|
const biggest = folders.reduce((m, f) => Math.max(m, f.total_bytes), 0);
|
||
|
|
const rows = folders.map((f) => `
|
||
|
|
<tr>
|
||
|
|
<td><strong>${esc(f.name)}</strong>${f.depth
|
||
|
|
? `<div class="hint">${esc(f.path)}</div>` : ''}</td>
|
||
|
|
<td class="mono">${fmtBytes(f.total_bytes)}</td>
|
||
|
|
<td class="mono hint">${fmtBytes(f.own_bytes)}</td>
|
||
|
|
<td class="hint">${f.total_files} file${f.total_files === 1 ? '' : 's'}</td>
|
||
|
|
<td style="width:30%">
|
||
|
|
<div class="usage-bar sm"><span class="bar-ok" style="width:${pct(f.total_bytes, biggest)}%"></span></div>
|
||
|
|
</td>
|
||
|
|
</tr>`).join('');
|
||
|
|
|
||
|
|
const rootRow = data.root_files.count ? `
|
||
|
|
<tr>
|
||
|
|
<td><strong>(files outside any folder)</strong></td>
|
||
|
|
<td class="mono">${fmtBytes(data.root_files.bytes)}</td>
|
||
|
|
<td class="mono hint">${fmtBytes(data.root_files.bytes)}</td>
|
||
|
|
<td class="hint">${data.root_files.count} file${data.root_files.count === 1 ? '' : 's'}</td>
|
||
|
|
<td><div class="usage-bar sm"><span class="bar-ok" style="width:${pct(data.root_files.bytes, biggest)}%"></span></div></td>
|
||
|
|
</tr>` : '';
|
||
|
|
|
||
|
|
return `
|
||
|
|
<div class="card">
|
||
|
|
<div class="card-head"><h3>Space used per folder</h3>
|
||
|
|
<div class="row-actions" style="justify-content:flex-end">
|
||
|
|
<button class="btn btn-sm ${sortBy === 'total' ? 'btn-primary' : ''}" id="sortSize">Largest first</button>
|
||
|
|
<button class="btn btn-sm ${sortBy === 'name' ? 'btn-primary' : ''}" id="sortName">By name</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="card-body">
|
||
|
|
<p class="hint" style="margin:0 0 12px">
|
||
|
|
<strong>Total</strong> includes everything nested inside the folder;
|
||
|
|
<strong>own</strong> counts only files sitting directly in it.
|
||
|
|
</p>
|
||
|
|
<table>
|
||
|
|
<thead><tr><th>Folder</th><th>Total</th><th>Own</th><th>Files</th><th></th></tr></thead>
|
||
|
|
<tbody>${rows || '<tr><td colspan="5" class="muted">No folders yet.</td></tr>'}${rootRow}</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function integrityCard() {
|
||
|
|
const i = data.integrity;
|
||
|
|
if (!i.missing.length && !i.orphans.length) {
|
||
|
|
return `<div class="card"><div class="card-head"><h3>Consistency</h3></div>
|
||
|
|
<div class="card-body"><p class="hint" style="margin:0">
|
||
|
|
Every recorded file is present on disk, and nothing on disk is unaccounted for.
|
||
|
|
</p></div></div>`;
|
||
|
|
}
|
||
|
|
return `
|
||
|
|
<div class="card">
|
||
|
|
<div class="card-head"><h3>Consistency</h3></div>
|
||
|
|
<div class="card-body">
|
||
|
|
${i.missing.length ? `
|
||
|
|
<div class="warn-banner">
|
||
|
|
<strong>${i.missing.length} recorded file(s) are missing from disk.</strong>
|
||
|
|
They will appear in listings but cannot be downloaded:
|
||
|
|
${esc(i.missing.slice(0, 5).map((m) => m.name).join(', '))}${i.missing.length > 5 ? '…' : ''}
|
||
|
|
</div>` : ''}
|
||
|
|
${i.orphans.length ? `
|
||
|
|
<div class="warn-banner">
|
||
|
|
<strong>${i.orphans.length} file(s) on disk are not referenced by any record</strong>
|
||
|
|
(${fmtBytes(i.orphan_bytes)}). These are safe to remove.
|
||
|
|
<button class="btn btn-sm" id="cleanOrphans" style="margin-left:8px">Reclaim space</button>
|
||
|
|
</div>` : ''}
|
||
|
|
</div>
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function render() {
|
||
|
|
content.innerHTML = `
|
||
|
|
<div class="page-head">
|
||
|
|
<h1>Storage</h1>
|
||
|
|
<p>Disk space on this server and how much of it each folder is using.</p>
|
||
|
|
</div>
|
||
|
|
${diskCard()}
|
||
|
|
${appCard()}
|
||
|
|
${foldersCard()}
|
||
|
|
${integrityCard()}
|
||
|
|
<p class="hint" style="margin-top:14px">
|
||
|
|
Measured ${esc(fmtDateTime(data.generated_at))} ·
|
||
|
|
<a href="#" id="refresh">Refresh</a>
|
||
|
|
</p>`;
|
||
|
|
|
||
|
|
document.getElementById('sortSize').onclick = () => { sortBy = 'total'; render(); };
|
||
|
|
document.getElementById('sortName').onclick = () => { sortBy = 'name'; render(); };
|
||
|
|
document.getElementById('refresh').onclick = (e) => { e.preventDefault(); load(); };
|
||
|
|
const clean = document.getElementById('cleanOrphans');
|
||
|
|
if (clean) clean.onclick = async () => {
|
||
|
|
if (!confirm('Permanently delete files on disk that no record points at?')) return;
|
||
|
|
try {
|
||
|
|
const r = await api('/api/storage/cleanup-orphans', { method: 'POST' });
|
||
|
|
toast(`Reclaimed ${fmtBytes(r.bytes)} from ${r.removed} file(s)`, 'ok');
|
||
|
|
load();
|
||
|
|
} catch (err) { toast(err.message, 'err'); }
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async function load() {
|
||
|
|
try {
|
||
|
|
data = await api('/api/storage');
|
||
|
|
render();
|
||
|
|
} catch (e) {
|
||
|
|
content.innerHTML = `<div class="page-head"><h1>Storage</h1></div>
|
||
|
|
<div class="card"><div class="card-body"><p>${esc(e.message)}</p></div></div>`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
load();
|
||
|
|
})();
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|