Files
2026-09-13 20:12:28 +01:00

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>