Ships Pithos (the s3d control panel) as a built-in extension: the dock menu opens it in a tab served from a loopback port, and "Stop s3d" shuts the gateway down. Generated by Pithos/scripts/build-theseus-addon.mjs. yaml is vendored under vendor/yaml/lib rather than node_modules/ or dist/, because Theseus git-ignores both and a clean-worktree release build would otherwise ship the add-on without its only dependency.
916 lines
46 KiB
JavaScript
916 lines
46 KiB
JavaScript
// Pithos UI. Plain DOM, no build step: every host serves these files as-is.
|
||
// All user content (object keys, user names, log lines) goes in via
|
||
// textContent through h(); nothing here assigns innerHTML.
|
||
|
||
// ---------------------------------------------------------------- helpers
|
||
|
||
function h(tag, attrs = {}, ...children) {
|
||
const el = document.createElement(tag);
|
||
for (const [k, v] of Object.entries(attrs || {})) {
|
||
if (v == null || v === false) continue;
|
||
if (k === 'class') el.className = v;
|
||
else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
||
else if (k === 'dataset') Object.assign(el.dataset, v);
|
||
else if (v === true) el.setAttribute(k, '');
|
||
else el.setAttribute(k, v);
|
||
}
|
||
for (const c of children.flat(Infinity)) {
|
||
if (c == null || c === false) continue;
|
||
el.append(c instanceof Node ? c : String(c));
|
||
}
|
||
return el;
|
||
}
|
||
|
||
const $ = (sel, root = document) => root.querySelector(sel);
|
||
|
||
// replaceChildren, but skipping the null/false that conditional children produce.
|
||
function put(el, ...children) {
|
||
el.replaceChildren(...children.flat(Infinity).filter((c) => c != null && c !== false));
|
||
}
|
||
|
||
async function api(method, path, body, { raw } = {}) {
|
||
const opts = { method, headers: { 'x-pithos': '1' }, credentials: 'same-origin' };
|
||
if (body !== undefined) {
|
||
opts.headers['content-type'] = 'application/json';
|
||
opts.body = JSON.stringify(body);
|
||
}
|
||
const res = await fetch(path, opts);
|
||
if (res.status === 401 && path !== '/api/session') { showSignin(); throw new Error('signed out'); }
|
||
if (raw) return res;
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||
return data;
|
||
}
|
||
|
||
function toast(msg, kind = '') {
|
||
const el = h('div', { class: `toast ${kind}` }, msg);
|
||
$('#toasts').append(el);
|
||
setTimeout(() => el.remove(), kind === 'error' ? 7000 : 3500);
|
||
}
|
||
|
||
const fail = (e) => { if (e.message !== 'signed out') toast(e.message, 'error'); };
|
||
|
||
function fmtBytes(n) {
|
||
if (n == null || isNaN(n)) return '–';
|
||
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
|
||
let i = 0;
|
||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||
return `${i ? n.toFixed(n < 10 ? 2 : 1) : n} ${u[i]}`;
|
||
}
|
||
function fmtDate(s) { if (!s) return '–'; const d = new Date(s); return isNaN(d) ? s : d.toLocaleString(); }
|
||
function fmtDuration(ms) {
|
||
const s = Math.floor(ms / 1000);
|
||
if (s < 60) return `${s}s`;
|
||
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
|
||
if (s < 86400) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
||
return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`;
|
||
}
|
||
|
||
async function copy(text, what = 'Copied') {
|
||
try { await navigator.clipboard.writeText(text); toast(what); }
|
||
catch { prompt('Copy:', text); }
|
||
}
|
||
|
||
function modal(title, body, buttons = []) {
|
||
return new Promise((resolve) => {
|
||
const close = (v) => { root.remove(); document.removeEventListener('keydown', onKey); resolve(v); };
|
||
const onKey = (e) => { if (e.key === 'Escape') close(undefined); };
|
||
const foot = h('div', { class: 'foot' }, buttons.map((b) => h('button', {
|
||
class: `btn ${b.kind || ''}`, type: b.submit ? 'submit' : 'button',
|
||
onclick: b.submit ? null : async () => { const v = b.value ? await b.value() : b.result; if (v !== false) close(v); },
|
||
}, b.label)));
|
||
const form = h('form', { class: 'modal', onsubmit: async (e) => {
|
||
e.preventDefault();
|
||
const b = buttons.find((x) => x.submit);
|
||
const v = b.value ? await b.value() : b.result;
|
||
if (v !== false) close(v);
|
||
} }, h('h2', {}, title), body, foot);
|
||
const root = h('div', { class: 'modal-backdrop', onmousedown: (e) => { if (e.target === root) close(undefined); } }, form);
|
||
$('#modal-root').append(root);
|
||
document.addEventListener('keydown', onKey);
|
||
setTimeout(() => (form.querySelector('input, textarea, select') || form.querySelector('.btn.primary'))?.focus(), 0);
|
||
});
|
||
}
|
||
|
||
function confirmModal(title, text, label = 'Delete', kind = 'danger') {
|
||
return modal(title, h('p', {}, text), [
|
||
{ label: 'Cancel', result: false },
|
||
{ label, kind: `primary ${kind === 'danger' ? '' : ''}`, submit: true, result: true },
|
||
]).then((v) => v === true);
|
||
}
|
||
|
||
function secretRow(label, value) {
|
||
return h('div', { class: 'stack' },
|
||
h('span', { class: 'small muted' }, label),
|
||
h('div', { class: 'secret' }, h('code', {}, value), h('button', { class: 'btn small', type: 'button', onclick: () => copy(value) }, 'Copy')));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- state
|
||
|
||
const state = {
|
||
status: null,
|
||
login: { state: 'idle' },
|
||
logs: [],
|
||
logSeq: 0,
|
||
selectedUser: localStorage.getItem('pithos.user') || '',
|
||
};
|
||
const listeners = new Set();
|
||
const onState = (fn) => { listeners.add(fn); return () => listeners.delete(fn); };
|
||
const emit = () => listeners.forEach((fn) => fn());
|
||
|
||
async function refreshStatus() {
|
||
try {
|
||
state.status = await api('GET', '/api/status');
|
||
state.login = state.status.login;
|
||
renderPill();
|
||
emit();
|
||
} catch (e) { fail(e); }
|
||
}
|
||
|
||
function connectEvents() {
|
||
const es = new EventSource('/api/events');
|
||
es.addEventListener('status', (e) => {
|
||
if (!state.status) return;
|
||
state.status.daemon = JSON.parse(e.data);
|
||
renderPill();
|
||
emit();
|
||
});
|
||
es.addEventListener('log', (e) => {
|
||
const l = JSON.parse(e.data);
|
||
if (l.seq <= state.logSeq) return;
|
||
state.logSeq = l.seq;
|
||
state.logs.push(l);
|
||
if (state.logs.length > 2000) state.logs.shift();
|
||
logSink?.(l);
|
||
});
|
||
es.addEventListener('login', (e) => { state.login = JSON.parse(e.data); emit(); });
|
||
es.addEventListener('install', (e) => { const p = JSON.parse(e.data); if (p.phase === 'extract') toast('Verified checksum, installing…'); });
|
||
es.onerror = () => { /* EventSource reconnects on its own */ };
|
||
}
|
||
|
||
function daemonState() {
|
||
const s = state.status;
|
||
if (!s) return 'stopped';
|
||
if (s.daemon.state === 'stopped' && s.external) return 'external';
|
||
if (s.daemon.state === 'crashed' && s.daemon.needsLogin) return 'not connected';
|
||
return s.daemon.state;
|
||
}
|
||
|
||
function renderPill() {
|
||
const st = daemonState();
|
||
const label = { running: 's3d running', starting: 'Starting…', stopping: 'Stopping…', crashed: 's3d stopped (error)', 'not connected': 'Not connected to Sia', stopped: 's3d stopped', external: 's3d running (external)' }[st];
|
||
const pill = $('#daemon-pill');
|
||
pill.querySelector('.dot').className = `dot ${st === 'external' ? 'running' : st === 'not connected' ? 'starting' : st}`;
|
||
pill.querySelector('.label').textContent = label;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- router
|
||
|
||
let cleanup = [];
|
||
let logSink = null;
|
||
const views = { overview, buckets, users, setup, settings, logs };
|
||
|
||
function route() {
|
||
cleanup.forEach((fn) => fn());
|
||
cleanup = [];
|
||
logSink = null;
|
||
const [, name = 'overview', ...rest] = location.hash.replace(/^#/, '').split('/');
|
||
const view = views[name] ? name : 'overview';
|
||
for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === view);
|
||
const main = $('#main');
|
||
put(main);
|
||
views[view](main, rest.map(decodeURIComponent));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- overview
|
||
|
||
function overview(main) {
|
||
const daemonCard = h('div', { class: 'card' });
|
||
const statsCard = h('div', { class: 'card' });
|
||
const banners = h('div');
|
||
main.append(
|
||
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Overview'), h('p', { class: 'muted' }, 'Your s3d gateway and its upload pipeline to Sia.'))),
|
||
banners,
|
||
h('div', { class: 'grid two' }, daemonCard, statsCard),
|
||
);
|
||
|
||
function draw() {
|
||
const s = state.status;
|
||
if (!s) return;
|
||
put(banners, ...bannersFor(s));
|
||
const st = daemonState();
|
||
const busy = st === 'starting' || st === 'stopping';
|
||
const running = st === 'running' || st === 'starting';
|
||
put(daemonCard,
|
||
h('div', { class: 'row spread' }, h('h2', {}, 'Gateway'), h('span', { class: `badge ${st === 'running' || st === 'external' ? 'ok' : st === 'crashed' ? 'warn' : ''}` }, st)),
|
||
h('dl', { class: 'kv' },
|
||
h('dt', {}, 'S3 endpoint'), h('dd', {}, h('code', {}, `http://${s.config.apiAddress}`)),
|
||
s.config.https && [h('dt', {}, 'S3 HTTPS'), h('dd', {}, h('code', {}, `https://${s.config.https}`))],
|
||
h('dt', {}, 's3d'), h('dd', {}, s.s3d?.version ? `v${s.s3d.version}` : (s.daemon.binary ? 'unknown version' : 'not installed')),
|
||
h('dt', {}, 'Uptime'), h('dd', {}, st === 'running' && s.daemon.startedAt ? fmtDuration(Date.now() - s.daemon.startedAt) : '–'),
|
||
h('dt', {}, 'Config'), h('dd', {}, h('code', {}, s.config.file)),
|
||
h('dt', {}, 'Data'), h('dd', {}, h('code', {}, s.config.directory)),
|
||
),
|
||
h('div', { class: 'row', style: 'margin-top:16px' },
|
||
st === 'external'
|
||
? h('span', { class: 'muted small' }, 'Another s3d process is serving this config. Stop it to manage it from here.')
|
||
: [
|
||
h('button', { class: 'btn primary', disabled: busy || running || !s.daemon.binary, onclick: () => daemonAction('start') }, 'Start'),
|
||
h('button', { class: 'btn', disabled: busy || !running, onclick: () => daemonAction('restart') }, 'Restart'),
|
||
h('button', { class: 'btn danger', disabled: busy || !running, onclick: () => daemonAction('stop') }, 'Stop'),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
async function drawStats() {
|
||
const st = daemonState();
|
||
if (st !== 'running' && st !== 'external') {
|
||
put(statsCard, h('h2', {}, 'Upload pipeline'), h('p', { class: 'muted' }, 'Start s3d to see live upload stats.'));
|
||
return;
|
||
}
|
||
try {
|
||
const x = await api('GET', '/api/stats');
|
||
const stat = (v, l, bad) => h('div', { class: `stat ${bad ? 'bad' : ''}` }, h('div', { class: 'v' }, v), h('div', { class: 'l' }, l));
|
||
put(statsCard,
|
||
h('div', { class: 'row spread' }, h('h2', {}, 'Upload pipeline'),
|
||
h('button', { class: 'btn small', disabled: !x.pendingObjects, title: 'Upload everything waiting in the local buffer now, even if slabs are not full', onclick: flushNow }, 'Flush now')),
|
||
h('div', { class: 'stats' },
|
||
stat(x.pendingObjects, `pending · ${fmtBytes(x.pendingSize)}`),
|
||
stat(x.uploadedObjects, `on Sia · ${fmtBytes(x.uploadedSize)}`),
|
||
stat(x.unpinnedObjects, 'awaiting pin'),
|
||
stat(x.multipartUploads, 'open multipart'),
|
||
stat(x.failedUploads, 'failed uploads', x.failedUploads > 0),
|
||
stat(x.orphanedObjects, 'orphaned', x.orphanedObjects > 0),
|
||
),
|
||
h('p', { class: 'small muted', style: 'margin:12px 0 0' }, 'New objects wait in a local buffer and are packed into full slabs before upload, which keeps wasted space down.'),
|
||
);
|
||
} catch (e) {
|
||
put(statsCard, h('h2', {}, 'Upload pipeline'), h('p', { class: 'error' }, e.message));
|
||
}
|
||
}
|
||
|
||
async function flushNow(ev) {
|
||
const btn = ev.currentTarget;
|
||
btn.disabled = true;
|
||
put(btn, h('span', { class: 'spinner' }), ' Flushing…');
|
||
try { await api('POST', '/api/flush'); toast('All pending objects are on Sia'); }
|
||
catch (e) { fail(e); }
|
||
drawStats();
|
||
}
|
||
|
||
draw();
|
||
drawStats();
|
||
cleanup.push(onState(draw));
|
||
let lastState = daemonState();
|
||
cleanup.push(onState(() => { if (daemonState() !== lastState) { lastState = daemonState(); drawStats(); } }));
|
||
const t = setInterval(() => { draw(); drawStats(); }, 4000);
|
||
cleanup.push(() => clearInterval(t));
|
||
}
|
||
|
||
function bannersFor(s) {
|
||
const out = [];
|
||
if (!s.daemon.binary) {
|
||
out.push(h('div', { class: 'banner warn' },
|
||
h('span', {}, `s3d is not installed. Pithos can download the official v${s.s3d.pinned} release and check its SHA-256.`),
|
||
s.s3d.installable && h('button', { class: 'btn primary', onclick: installBinary }, `Install s3d v${s.s3d.pinned}`)));
|
||
} else if (s.s3d?.outdated) {
|
||
out.push(h('div', { class: 'banner warn' },
|
||
h('span', {}, `s3d v${s.s3d.version} is older than v${s.s3d.pinned}. User and key listing needs v0.2.0 or newer.`),
|
||
s.s3d.installable && h('button', { class: 'btn', onclick: installBinary }, `Install v${s.s3d.pinned}`)));
|
||
}
|
||
if (s.registration?.registered === false || s.daemon.needsLogin) {
|
||
out.push(h('div', { class: 'banner info' },
|
||
h('span', {}, 'This s3d is not connected to a Sia indexer yet.'),
|
||
h('a', { class: 'btn primary', href: '#/setup' }, 'Connect to Sia')));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
async function installBinary(ev) {
|
||
const btn = ev.currentTarget;
|
||
btn.disabled = true;
|
||
put(btn, h('span', { class: 'spinner' }), ' Downloading…');
|
||
try {
|
||
const r = await api('POST', '/api/binary/install');
|
||
toast(`Installed s3d v${r.version?.version || ''}`);
|
||
} catch (e) { fail(e); }
|
||
refreshStatus();
|
||
}
|
||
|
||
async function daemonAction(action) {
|
||
try {
|
||
await api('POST', `/api/daemon/${action}`);
|
||
await refreshStatus();
|
||
} catch (e) { fail(e); }
|
||
}
|
||
|
||
// ---------------------------------------------------------------- setup (indexer login)
|
||
|
||
function setup(main) {
|
||
const body = h('div', { class: 'card' });
|
||
main.append(
|
||
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Sia connection'),
|
||
h('p', { class: 'muted' }, 'Register this s3d with a Sia indexer. You do this once; the indexer then handles hosts and contracts for it.'))),
|
||
body,
|
||
);
|
||
|
||
let draft = { indexerUrl: 'https://sia.storage', phrase: '', mode: 'existing' };
|
||
|
||
function draw() {
|
||
let l = state.login;
|
||
// Registered already (e.g. on an earlier run): show that instead of the form.
|
||
if (l.state === 'idle' && state.status?.registration?.registered) l = { state: 'already', indexerUrl: state.status.registration.indexerUrl };
|
||
const step = ['idle', 'failed'].includes(l.state) ? 0 : ['done', 'already'].includes(l.state) ? 2 : 1;
|
||
const steps = h('div', { class: 'steps' }, ['Recovery phrase', 'Approve on the indexer', 'Connected'].map((t, i) => h('span', { class: i === step ? 'on' : '' }, `${i + 1}. ${t}`)));
|
||
|
||
if (l.state === 'done' || l.state === 'already') {
|
||
put(body, steps,
|
||
h('h2', {}, l.state === 'done' ? 'Connected' : 'Already connected'),
|
||
h('p', {}, l.indexerUrl ? ['This s3d is registered with ', h('code', {}, l.indexerUrl), '.'] : 'This s3d is registered with an indexer.'),
|
||
h('div', { class: 'row' },
|
||
daemonState() !== 'running' && h('button', { class: 'btn primary', onclick: async () => { await daemonAction(daemonState() === 'crashed' ? 'restart' : 'start'); location.hash = '#/overview'; } }, 'Start s3d'),
|
||
h('a', { class: 'btn', href: '#/users' }, 'Create a user'),
|
||
h('button', { class: 'btn', onclick: () => api('POST', '/api/login/cancel').catch(fail) }, 'Done')));
|
||
return;
|
||
}
|
||
|
||
if (l.state === 'confirm') {
|
||
const words = (l.generatedPhrase || '').split(/\s+/);
|
||
const ack = h('input', { type: 'checkbox', id: 'ack' });
|
||
const go = h('button', { class: 'btn primary', disabled: true, onclick: () => api('POST', '/api/login/confirm').catch(fail) }, 'Continue');
|
||
ack.addEventListener('change', () => { go.disabled = !ack.checked; });
|
||
put(body, steps,
|
||
h('h2', {}, 'Write down your new recovery phrase'),
|
||
h('p', {}, 'This phrase is the only way to register this storage again on another machine or after a reinstall. Pithos does not keep it. Store it offline.'),
|
||
h('div', { class: 'phrase' }, words.map((w) => h('span', {}, w))),
|
||
h('div', { class: 'row', style: 'margin-top:14px' }, h('button', { class: 'btn small', onclick: () => copy(l.generatedPhrase, 'Phrase copied - clear your clipboard after storing it') }, 'Copy')),
|
||
h('label', { class: 'check', style: 'margin:16px 0' }, ack, 'I have stored the phrase somewhere safe'),
|
||
h('div', { class: 'row' }, go, h('button', { class: 'btn', onclick: cancel }, 'Cancel')));
|
||
return;
|
||
}
|
||
|
||
if (l.state === 'terminal') {
|
||
put(body, steps,
|
||
h('h2', {}, 'Finish in the console window'),
|
||
h('p', {}, 'A console window running "s3d login" has opened. Enter the indexer URL and your recovery phrase there, then open the approval link it prints. This page updates when the window closes.'),
|
||
h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ' Waiting for the login window…'),
|
||
h('div', { class: 'row' }, h('button', { class: 'btn danger', onclick: cancel }, 'Cancel')));
|
||
return;
|
||
}
|
||
|
||
if (step === 1) {
|
||
const waiting = l.state === 'approve' ? null : h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ' Talking to the indexer…');
|
||
put(body, steps,
|
||
h('h2', {}, 'Approve the connection'),
|
||
waiting,
|
||
l.approvalUrl && [
|
||
h('p', {}, 'Open this link, sign in to your indexer account and approve the "S3d" app. This page continues by itself once you approve.'),
|
||
h('div', { class: 'secret' }, h('code', {}, l.approvalUrl)),
|
||
h('div', { class: 'row', style: 'margin-top:12px' },
|
||
h('button', { class: 'btn primary', onclick: () => openLink(l.approvalUrl) }, 'Open approval page'),
|
||
h('button', { class: 'btn', onclick: () => copy(l.approvalUrl) }, 'Copy link')),
|
||
h('p', { class: 'muted row', style: 'margin-top:16px' }, h('span', { class: 'spinner' }), ' Waiting for approval…'),
|
||
],
|
||
h('div', { class: 'row', style: 'margin-top:12px' }, h('button', { class: 'btn danger', onclick: cancel }, 'Cancel')));
|
||
return;
|
||
}
|
||
|
||
// step 0: form
|
||
const url = h('input', { type: 'url', value: draft.indexerUrl, required: true, oninput: (e) => { draft.indexerUrl = e.target.value; } });
|
||
const phrase = h('textarea', { rows: 3, autocomplete: 'off', spellcheck: 'false', placeholder: 'twelve words separated by spaces', oninput: (e) => { draft.phrase = e.target.value; } });
|
||
phrase.value = draft.phrase;
|
||
const radios = h('div', { class: 'stack' },
|
||
h('label', { class: 'check' }, h('input', { type: 'radio', name: 'mode', checked: draft.mode === 'existing', onchange: () => { draft.mode = 'existing'; draw(); } }), 'I have a recovery phrase'),
|
||
h('label', { class: 'check' }, h('input', { type: 'radio', name: 'mode', checked: draft.mode === 'new', onchange: () => { draft.mode = 'new'; draw(); } }), 'Generate a new one'));
|
||
put(body, steps,
|
||
l.state === 'failed' && h('div', { class: 'banner warn' }, h('span', {}, l.error || 'Login failed')),
|
||
h('form', { class: 'stack', onsubmit: start },
|
||
h('label', { class: 'field' }, h('span', {}, 'Indexer'), url, h('small', {}, 'https://sia.storage is the hosted indexer run by the Sia Foundation. Use your own indexd URL if you run one.')),
|
||
radios,
|
||
draft.mode === 'existing' && h('label', { class: 'field' }, h('span', {}, 'Recovery phrase'), phrase,
|
||
h('small', {}, 'Sent only to the local s3d process. It derives this app\'s key and is not stored by Pithos.')),
|
||
h('div', { class: 'row' }, h('button', { class: 'btn primary', type: 'submit' }, 'Connect'))));
|
||
}
|
||
|
||
async function start(e) {
|
||
e.preventDefault();
|
||
const phrase = draft.mode === 'existing' ? draft.phrase.trim() : '';
|
||
if (draft.mode === 'existing' && phrase.split(/\s+/).length !== 12) return toast('A recovery phrase is 12 words', 'error');
|
||
try {
|
||
await api('POST', '/api/login', { indexerUrl: draft.indexerUrl, phrase });
|
||
draft.phrase = '';
|
||
} catch (err) { fail(err); }
|
||
}
|
||
|
||
async function cancel() { try { await api('POST', '/api/login/cancel'); } catch (e) { fail(e); } }
|
||
|
||
draw();
|
||
cleanup.push(onState(draw));
|
||
}
|
||
|
||
async function openLink(url) {
|
||
const host = state.status?.host;
|
||
if (host === 'desktop' || host === 'theseus') {
|
||
try { await api('POST', '/api/open-external', { url }); return; } catch { /* fall through */ }
|
||
}
|
||
window.open(url, '_blank', 'noopener');
|
||
}
|
||
|
||
// ---------------------------------------------------------------- users & keys
|
||
|
||
function users(main) {
|
||
const list = h('div', { class: 'card' });
|
||
main.append(
|
||
h('div', { class: 'page-head' },
|
||
h('div', {}, h('h1', {}, 'Users & keys'), h('p', { class: 'muted' }, 'Each S3 user owns its own buckets. Access keys let S3 clients (aws, rclone, apps) act as that user.')),
|
||
h('button', { class: 'btn primary', onclick: createUser }, 'New user')),
|
||
list,
|
||
);
|
||
|
||
let expanded = new Set();
|
||
|
||
async function load() {
|
||
let rows;
|
||
try { rows = await api('GET', '/api/users'); }
|
||
catch (e) { put(list, h('p', { class: 'error' }, e.message)); return; }
|
||
if (!rows.length) {
|
||
put(list, h('div', { class: 'empty' }, h('p', {}, 'No users yet.'), h('button', { class: 'btn primary', onclick: createUser }, 'Create the first user')));
|
||
return;
|
||
}
|
||
const keys = await api('GET', '/api/keys').catch(() => []);
|
||
put(list, h('table', {},
|
||
h('thead', {}, h('tr', {}, h('th', {}, 'User'), h('th', {}, 'Access keys'), h('th', {}))),
|
||
h('tbody', {}, rows.flatMap((u) => {
|
||
const open = expanded.has(u.name);
|
||
const userKeys = keys.filter((k) => k.user === u.name);
|
||
const main = h('tr', { class: 'clickable', onclick: () => { open ? expanded.delete(u.name) : expanded.add(u.name); load(); } },
|
||
h('td', {}, h('strong', {}, u.name)),
|
||
h('td', {}, `${u.keys.length}`),
|
||
h('td', { class: 'actions' },
|
||
h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); state.selectedUser = u.name; localStorage.setItem('pithos.user', u.name); location.hash = '#/buckets'; } }, 'Buckets'),
|
||
' ',
|
||
h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); newKey(u.name); } }, 'New key'),
|
||
' ',
|
||
h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteUser(u.name); } }, 'Delete')));
|
||
if (!open) return [main];
|
||
const detail = h('tr', {}, h('td', { colspan: 3, style: 'background:var(--surface-2)' },
|
||
userKeys.length ? h('table', {}, h('tbody', {}, userKeys.map((k) => h('tr', {},
|
||
h('td', {}, h('code', {}, k.accessKeyId)),
|
||
h('td', { class: 'actions' },
|
||
h('button', { class: 'btn small', onclick: () => showKey(k) }, 'Show secret & config'),
|
||
' ',
|
||
h('button', { class: 'btn small danger', onclick: () => deleteKey(k.accessKeyId) }, 'Revoke'))))))
|
||
: h('p', { class: 'muted', style: 'margin:0' }, 'No keys. Create one to use S3 clients or browse buckets.')));
|
||
return [main, detail];
|
||
}))));
|
||
}
|
||
|
||
async function createUser() {
|
||
const name = h('input', { type: 'text', required: true, pattern: '[A-Za-z0-9][A-Za-z0-9._@-]{0,63}', placeholder: 'e.g. backups' });
|
||
const withKey = h('input', { type: 'checkbox', checked: true });
|
||
const v = await modal('New user', h('div', { class: 'stack' },
|
||
h('label', { class: 'field' }, h('span', {}, 'Name'), name, h('small', {}, 'Letters, digits, . _ @ -')),
|
||
h('label', { class: 'check' }, withKey, 'Create an access key now')),
|
||
[{ label: 'Cancel', result: null }, { label: 'Create', kind: 'primary', submit: true, value: () => ({ name: name.value.trim(), withKey: withKey.checked }) }]);
|
||
if (!v) return;
|
||
try {
|
||
const r = await api('POST', '/api/users', v);
|
||
toast(`Created ${r.name}`);
|
||
expanded.add(r.name);
|
||
if (!state.selectedUser) { state.selectedUser = r.name; localStorage.setItem('pithos.user', r.name); }
|
||
await load();
|
||
if (r.key) showKey(r.key, true);
|
||
} catch (e) { fail(e); }
|
||
}
|
||
|
||
async function newKey(user) {
|
||
try {
|
||
const k = await api('POST', '/api/keys', { user });
|
||
expanded.add(user);
|
||
await load();
|
||
showKey(k, true);
|
||
} catch (e) { fail(e); }
|
||
}
|
||
|
||
async function deleteUser(name) {
|
||
if (!(await confirmModal(`Delete ${name}?`, 'Its access keys stop working immediately. s3d refuses if the user still owns buckets.'))) return;
|
||
try { await api('DELETE', `/api/users/${encodeURIComponent(name)}`); toast(`Deleted ${name}`); load(); } catch (e) { fail(e); }
|
||
}
|
||
|
||
async function deleteKey(id) {
|
||
if (!(await confirmModal('Revoke this key?', `Clients using ${id} will be refused from now on.`, 'Revoke'))) return;
|
||
try { await api('DELETE', `/api/keys/${encodeURIComponent(id)}`); toast('Key revoked'); load(); } catch (e) { fail(e); }
|
||
}
|
||
|
||
load();
|
||
}
|
||
|
||
function showKey(k, fresh) {
|
||
const s = state.status;
|
||
const endpoint = `http://${s?.config.apiAddress || '127.0.0.1:8000'}`;
|
||
const aws = `aws configure set aws_access_key_id ${k.accessKeyId} --profile ${k.user}
|
||
aws configure set aws_secret_access_key ${k.secretKey} --profile ${k.user}
|
||
aws configure set region us-east-1 --profile ${k.user}
|
||
aws --profile ${k.user} --endpoint-url ${endpoint} s3 ls`;
|
||
const rclone = `[sia-${k.user}]
|
||
type = s3
|
||
provider = Other
|
||
access_key_id = ${k.accessKeyId}
|
||
secret_access_key = ${k.secretKey}
|
||
endpoint = ${endpoint}
|
||
region = us-east-1
|
||
force_path_style = true`;
|
||
const env = `AWS_ACCESS_KEY_ID=${k.accessKeyId}
|
||
AWS_SECRET_ACCESS_KEY=${k.secretKey}
|
||
AWS_REGION=us-east-1
|
||
AWS_ENDPOINT_URL=${endpoint}`;
|
||
const tabs = { 'aws cli': aws, rclone, env };
|
||
const pre = h('pre', { class: 'snippet' }, aws);
|
||
const tabBtns = h('div', { class: 'row' }, Object.keys(tabs).map((t, i) => h('button', {
|
||
type: 'button', class: `btn small ${i === 0 ? 'primary' : ''}`,
|
||
onclick: (e) => { for (const b of tabBtns.children) b.classList.remove('primary'); e.currentTarget.classList.add('primary'); pre.textContent = tabs[t]; },
|
||
}, t)));
|
||
modal(fresh ? `Access key for ${k.user}` : `Key ${k.accessKeyId}`, h('div', { class: 'stack' },
|
||
fresh && h('p', { class: 'small muted', style: 'margin:0' }, 'Treat the secret like a password. You can view it again here later.'),
|
||
secretRow('Access key ID', k.accessKeyId),
|
||
secretRow('Secret key', k.secretKey),
|
||
h('h3', { style: 'margin-top:8px' }, 'Client config'),
|
||
tabBtns, pre,
|
||
h('div', {}, h('button', { class: 'btn small', type: 'button', onclick: () => copy(pre.textContent) }, 'Copy snippet'))),
|
||
[{ label: 'Done', kind: 'primary', submit: true, result: true }]);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- buckets & objects
|
||
|
||
function buckets(main, [bucket, ...prefixParts]) {
|
||
const head = h('div', { class: 'page-head' });
|
||
const body = h('div');
|
||
main.append(head, body);
|
||
|
||
let users = [];
|
||
const userSel = h('select', { onchange: (e) => { state.selectedUser = e.target.value; localStorage.setItem('pithos.user', e.target.value); location.hash = '#/buckets'; route(); } });
|
||
|
||
(async () => {
|
||
try { users = await api('GET', '/api/users'); }
|
||
catch (e) { put(body, h('div', { class: 'card' }, h('p', { class: 'error' }, e.message))); return; }
|
||
const usable = users.filter((u) => u.keys.length);
|
||
if (!usable.length) {
|
||
put(head, h('div', {}, h('h1', {}, 'Buckets')));
|
||
put(body, h('div', { class: 'card empty' }, h('p', {}, users.length ? 'None of your users has an access key yet.' : 'Create a user first; buckets belong to users.'), h('a', { class: 'btn primary', href: '#/users' }, 'Users & keys')));
|
||
return;
|
||
}
|
||
if (!usable.some((u) => u.name === state.selectedUser)) state.selectedUser = usable[0].name;
|
||
put(userSel, ...usable.map((u) => h('option', { value: u.name, selected: u.name === state.selectedUser }, u.name)));
|
||
const st = daemonState();
|
||
if (st !== 'running' && st !== 'external') {
|
||
put(head, h('div', {}, h('h1', {}, 'Buckets')));
|
||
put(body, h('div', { class: 'card empty' }, h('p', {}, 's3d is not running.'), h('button', { class: 'btn primary', onclick: async () => { await daemonAction('start'); setTimeout(route, 1500); } }, 'Start s3d')));
|
||
return;
|
||
}
|
||
if (bucket) objectBrowser(head, body, state.selectedUser, bucket, prefixParts.join('/'), userSel);
|
||
else bucketList(head, body, state.selectedUser, userSel);
|
||
})();
|
||
}
|
||
|
||
function userPicker(userSel) {
|
||
return h('label', { class: 'row small' }, h('span', { class: 'muted' }, 'Acting as'), h('span', { style: 'width:180px' }, userSel));
|
||
}
|
||
|
||
async function bucketList(head, body, user, userSel) {
|
||
put(head,
|
||
h('div', {}, h('h1', {}, 'Buckets'), h('p', { class: 'muted' }, 'Buckets owned by the selected user.')),
|
||
h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn primary', onclick: create }, 'New bucket')));
|
||
const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…'));
|
||
put(body, card);
|
||
const base = `/api/s3/${encodeURIComponent(user)}/buckets`;
|
||
|
||
async function load() {
|
||
try {
|
||
const list = await api('GET', base);
|
||
if (!list.length) { put(card, h('div', { class: 'empty' }, h('p', {}, 'No buckets yet.'), h('button', { class: 'btn primary', onclick: create }, 'Create a bucket'))); return; }
|
||
put(card, h('table', {},
|
||
h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'hide-sm' }, 'Created'), h('th', {}, 'Access'), h('th', {}))),
|
||
h('tbody', {}, list.map((b) => {
|
||
const access = h('td', {}, h('span', { class: 'badge' }, '…'));
|
||
api('GET', `${base}/${encodeURIComponent(b.name)}/policy`).then((p) => {
|
||
put(access, h('span', { class: `badge ${p.public === 'private' ? '' : 'warn'}` }, { private: 'private', read: 'public read', 'read-list': 'public read + list', custom: 'custom policy' }[p.public]));
|
||
}).catch(() => put(access, h('span', { class: 'badge' }, '?')));
|
||
return h('tr', { class: 'clickable', onclick: () => { location.hash = `#/buckets/${encodeURIComponent(b.name)}`; } },
|
||
h('td', {}, h('strong', {}, b.name)),
|
||
h('td', { class: 'hide-sm muted' }, fmtDate(b.created)),
|
||
access,
|
||
h('td', { class: 'actions' },
|
||
h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); accessDialog(user, b.name).then(load); } }, 'Access'),
|
||
' ',
|
||
h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); remove(b.name); } }, 'Delete')));
|
||
}))));
|
||
} catch (e) { put(card, h('p', { class: 'error' }, e.message)); }
|
||
}
|
||
|
||
async function create() {
|
||
const name = h('input', { type: 'text', required: true, pattern: '[a-z0-9][a-z0-9.\\-]{1,61}[a-z0-9]', placeholder: 'my-bucket' });
|
||
const v = await modal('New bucket', h('label', { class: 'field' }, h('span', {}, 'Name'), name, h('small', {}, '3-63 characters: lowercase letters, digits, dots and hyphens. Names are unique across this s3d.')),
|
||
[{ label: 'Cancel', result: null }, { label: 'Create', kind: 'primary', submit: true, value: () => name.value.trim() }]);
|
||
if (!v) return;
|
||
try { await api('POST', base, { name: v }); toast(`Created ${v}`); load(); } catch (e) { fail(e); }
|
||
}
|
||
|
||
async function remove(name) {
|
||
if (!(await confirmModal(`Delete bucket ${name}?`, 'The bucket must be empty. This cannot be undone.'))) return;
|
||
try { await api('DELETE', `${base}/${encodeURIComponent(name)}`); toast(`Deleted ${name}`); load(); } catch (e) { fail(e); }
|
||
}
|
||
|
||
load();
|
||
}
|
||
|
||
async function accessDialog(user, bucket) {
|
||
const base = `/api/s3/${encodeURIComponent(user)}/buckets/${encodeURIComponent(bucket)}/policy`;
|
||
let current = 'private';
|
||
try { current = (await api('GET', base)).public; } catch (e) { return fail(e); }
|
||
const opts = [
|
||
['private', 'Private', 'Only this user\'s access keys can read.'],
|
||
['read', 'Public read', 'Anyone who knows an object\'s URL can download it.'],
|
||
['read-list', 'Public read + list', 'Anyone can also list the bucket\'s contents.'],
|
||
];
|
||
let choice = current === 'custom' ? 'private' : current;
|
||
const endpoint = `http://${state.status?.config.apiAddress}`;
|
||
const v = await modal(`Access for ${bucket}`, h('div', { class: 'stack' },
|
||
current === 'custom' && h('div', { class: 'banner warn' }, 'This bucket has a custom policy. Saving replaces it.'),
|
||
opts.map(([val, label, desc]) => h('label', { class: 'check', style: 'align-items:flex-start' },
|
||
h('input', { type: 'radio', name: 'acc', checked: choice === val, onchange: () => { choice = val; } }),
|
||
h('span', {}, h('strong', {}, label), h('br'), h('span', { class: 'small muted' }, desc)))),
|
||
h('p', { class: 'small muted', style: 'margin:0' }, 'Public objects are served at ', h('code', {}, `${endpoint}/${bucket}/<key>`), '. Writes and deletes always need a key.')),
|
||
[{ label: 'Cancel', result: null }, { label: 'Save', kind: 'primary', submit: true, value: () => choice }]);
|
||
if (!v || v === current) return;
|
||
try { await api('PUT', base, { mode: v }); toast('Access updated'); } catch (e) { fail(e); }
|
||
}
|
||
|
||
function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
||
if (prefix && !prefix.endsWith('/')) prefix += '/';
|
||
const base = `/api/s3/${encodeURIComponent(user)}/buckets/${encodeURIComponent(bucket)}`;
|
||
const go = (p) => { location.hash = `#/buckets/${encodeURIComponent(bucket)}${p ? '/' + p.split('/').filter(Boolean).map(encodeURIComponent).join('/') : ''}`; };
|
||
|
||
const crumbs = h('div', { class: 'crumbs' },
|
||
h('button', { onclick: () => { location.hash = '#/buckets'; } }, 'Buckets'), h('span', { class: 'sep' }, '/'),
|
||
h('button', { onclick: () => go('') }, bucket),
|
||
prefix.split('/').filter(Boolean).map((part, i, all) => [h('span', { class: 'sep' }, '/'), h('button', { onclick: () => go(all.slice(0, i + 1).join('/')) }, part)]));
|
||
|
||
const fileInput = h('input', { type: 'file', multiple: true, hidden: true, onchange: (e) => { uploadFiles([...e.target.files]); e.target.value = ''; } });
|
||
put(head,
|
||
h('div', { class: 'stack' }, crumbs),
|
||
h('div', { class: 'row' }, userPicker(userSel),
|
||
h('button', { class: 'btn', onclick: newFolder }, 'New folder'),
|
||
h('button', { class: 'btn', onclick: () => accessDialog(user, bucket) }, 'Access'),
|
||
h('button', { class: 'btn primary', onclick: () => fileInput.click() }, 'Upload'), fileInput));
|
||
|
||
const uploads = h('div', { class: 'uploads' });
|
||
const card = h('div', { class: 'card dropzone' }, h('p', { class: 'muted' }, 'Loading…'));
|
||
put(body, uploads, card);
|
||
|
||
let depth = 0;
|
||
card.addEventListener('dragenter', (e) => { e.preventDefault(); depth++; card.classList.add('over'); });
|
||
card.addEventListener('dragleave', () => { if (--depth <= 0) { depth = 0; card.classList.remove('over'); } });
|
||
card.addEventListener('dragover', (e) => e.preventDefault());
|
||
card.addEventListener('drop', (e) => {
|
||
e.preventDefault(); depth = 0; card.classList.remove('over');
|
||
uploadFiles([...e.dataTransfer.files]);
|
||
});
|
||
|
||
let pages = [];
|
||
async function load(token) {
|
||
try {
|
||
const q = new URLSearchParams({ prefix });
|
||
if (token) q.set('token', token);
|
||
const page = await api('GET', `${base}/objects?${q}`);
|
||
pages = token ? [...pages, page] : [page];
|
||
draw();
|
||
} catch (e) { put(card, h('p', { class: 'error' }, e.message)); }
|
||
}
|
||
|
||
function draw() {
|
||
const folders = pages.flatMap((p) => p.folders);
|
||
const objects = pages.flatMap((p) => p.objects).filter((o) => o.key !== prefix);
|
||
const last = pages[pages.length - 1];
|
||
if (!folders.length && !objects.length) {
|
||
put(card, h('div', { class: 'empty' }, h('p', {}, prefix ? 'This folder is empty.' : 'This bucket is empty.'), h('p', { class: 'small' }, 'Drag files here or use Upload.')));
|
||
return;
|
||
}
|
||
put(card, h('table', {},
|
||
h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'num' }, 'Size'), h('th', { class: 'hide-sm' }, 'Modified'), h('th', {}))),
|
||
h('tbody', {},
|
||
folders.map((f) => h('tr', { class: 'clickable', onclick: () => go(f) },
|
||
h('td', {}, '📁 ', f.slice(prefix.length)),
|
||
h('td', { class: 'num muted' }, '–'), h('td', { class: 'hide-sm' }),
|
||
h('td', { class: 'actions' }, h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteFolder(f); } }, 'Delete')))),
|
||
objects.map((o) => h('tr', {},
|
||
h('td', {}, h('a', { href: `${base}/object?key=${encodeURIComponent(o.key)}`, download: '' }, o.key.slice(prefix.length))),
|
||
h('td', { class: 'num' }, fmtBytes(o.size)),
|
||
h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)),
|
||
h('td', { class: 'actions' },
|
||
h('button', { class: 'btn small', onclick: () => share(o.key) }, 'Share'),
|
||
' ',
|
||
h('button', { class: 'btn small danger', onclick: () => deleteObject(o.key) }, 'Delete')))))),
|
||
last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more')));
|
||
}
|
||
|
||
function uploadFiles(files) {
|
||
for (const file of files) {
|
||
if (file.size > 5 * 1024 ** 3) { toast(`${file.name}: single uploads are limited to 5 GiB`, 'error'); continue; }
|
||
const key = prefix + file.name;
|
||
const bar = h('i');
|
||
const pct = h('span', { class: 'muted' }, '0%');
|
||
const row = h('div', { class: 'upload' }, h('span', {}, key), h('div', { class: 'bar' }, bar), pct);
|
||
uploads.append(row);
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open('PUT', `${base}/object?key=${encodeURIComponent(key)}`);
|
||
xhr.setRequestHeader('x-pithos', '1');
|
||
if (file.type) xhr.setRequestHeader('content-type', file.type);
|
||
xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const p = Math.round((e.loaded / e.total) * 100); bar.style.width = p + '%'; pct.textContent = p + '%'; } };
|
||
xhr.onload = () => {
|
||
if (xhr.status >= 200 && xhr.status < 300) { pct.textContent = 'done'; setTimeout(() => row.remove(), 1500); load(); }
|
||
else { pct.textContent = 'failed'; pct.className = 'error'; let m = xhr.statusText; try { m = JSON.parse(xhr.responseText).error; } catch {} toast(`${file.name}: ${m}`, 'error'); }
|
||
};
|
||
xhr.onerror = () => { pct.textContent = 'failed'; pct.className = 'error'; toast(`${file.name}: network error`, 'error'); };
|
||
xhr.send(file);
|
||
}
|
||
}
|
||
|
||
async function newFolder() {
|
||
const name = h('input', { type: 'text', required: true, pattern: '[^/]+', placeholder: 'photos' });
|
||
const v = await modal('New folder', h('label', { class: 'field' }, h('span', {}, 'Name'), name, h('small', {}, 'S3 has no real folders. This opens an empty prefix; it appears once you upload into it.')),
|
||
[{ label: 'Cancel', result: null }, { label: 'Open', kind: 'primary', submit: true, value: () => name.value.trim() }]);
|
||
if (v) go(prefix + v);
|
||
}
|
||
|
||
async function deleteObject(key) {
|
||
if (!(await confirmModal('Delete object?', key))) return;
|
||
try { await api('DELETE', `${base}/object?key=${encodeURIComponent(key)}`); toast('Deleted'); load(); } catch (e) { fail(e); }
|
||
}
|
||
|
||
async function deleteFolder(p) {
|
||
if (!(await confirmModal('Delete folder?', `Every object under ${p} will be deleted.`))) return;
|
||
try { const r = await api('DELETE', `${base}/prefix?prefix=${encodeURIComponent(p)}`); toast(`Deleted ${r.deleted} objects`); load(); } catch (e) { fail(e); }
|
||
}
|
||
|
||
async function share(key) {
|
||
const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days (max)']].map(([v, l]) => h('option', { value: v }, l)));
|
||
const savedEndpoint = localStorage.getItem('pithos.publicEndpoint') || '';
|
||
const endpoint = h('input', { type: 'url', value: savedEndpoint, placeholder: `http://${state.status?.config.apiAddress}` });
|
||
const out = h('div');
|
||
const gen = async () => {
|
||
const q = new URLSearchParams({ key, expires: expires.value });
|
||
if (endpoint.value.trim()) { q.set('endpoint', endpoint.value.trim()); localStorage.setItem('pithos.publicEndpoint', endpoint.value.trim()); }
|
||
try {
|
||
const { url } = await api('GET', `${base}/presign?${q}`);
|
||
put(out, secretRow('Link', url));
|
||
} catch (e) { fail(e); }
|
||
return false;
|
||
};
|
||
await modal('Share link', h('div', { class: 'stack' },
|
||
h('p', { class: 'small muted', style: 'margin:0' }, 'A signed link that works without a key until it expires.'),
|
||
h('label', { class: 'field' }, h('span', {}, 'Valid for'), expires),
|
||
h('label', { class: 'field' }, h('span', {}, 'Public address of s3d'), endpoint, h('small', {}, 'The address recipients will use to reach s3d (e.g. behind a reverse proxy). Leave blank for the local endpoint.')),
|
||
out),
|
||
[{ label: 'Close', result: true }, { label: 'Create link', kind: 'primary', submit: true, value: gen }]);
|
||
}
|
||
|
||
load();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- settings
|
||
|
||
function settings(main) {
|
||
const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…'));
|
||
main.append(h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Settings'), h('p', { class: 'muted' }, 'Edits s3d.yml. s3d reads it at start, so a restart applies the changes.'))), card);
|
||
|
||
const fields = [
|
||
['Network', [
|
||
['apiAddress', 'S3 API address', 'text', 'host:port the S3 API listens on. Use 0.0.0.0:8000 to serve other machines.'],
|
||
['apiHTTPSAddress', 'S3 HTTPS address', 'text', 'Optional. s3d serves a self-signed certificate here.'],
|
||
['adminAddress', 'Admin API address', 'text', 'Pithos reads stats from here. Keep it on loopback.'],
|
||
['adminPassword', 'Admin API password', 'password', 'Generated on first start.'],
|
||
['s3.hostBases', 'Virtual-host bases', 'list', 'Comma-separated domains for bucket.domain style addressing.'],
|
||
]],
|
||
['Storage', [
|
||
['directory', 'Data directory', 'text', 'Database and upload buffer. Changing it points s3d at a different store.'],
|
||
['sia.diskUsageLimit', 'Buffer disk limit (bytes)', 'number', 'Upper bound for objects waiting to upload.'],
|
||
['sia.uploadThreads', 'Upload threads', 'number', ''],
|
||
['sia.dataShards', 'Data shards', 'number', 'Erasure coding: data shards (default 10). Applies to new uploads only.'],
|
||
['sia.parityShards', 'Parity shards', 'number', 'Erasure coding: parity shards (default 20).'],
|
||
]],
|
||
['Logging', [
|
||
['log.stdout.level', 'Console log level', 'select:debug,info,warn,error', ''],
|
||
['log.file.enabled', 'Write a log file', 'bool', ''],
|
||
['log.file.level', 'File log level', 'select:debug,info,warn,error', ''],
|
||
]],
|
||
];
|
||
|
||
(async () => {
|
||
let cfg;
|
||
try { cfg = await api('GET', '/api/config'); } catch (e) { put(card, h('p', { class: 'error' }, e.message)); return; }
|
||
const inputs = {};
|
||
const sections = fields.map(([title, list]) => h('div', { class: 'stack', style: 'margin-bottom:22px' }, h('h2', {}, title), h('div', { class: 'form-grid' }, list.map(([key, label, type, help]) => {
|
||
const v = cfg.values[key];
|
||
let input;
|
||
if (type === 'bool') input = h('input', { type: 'checkbox', checked: !!v });
|
||
else if (type.startsWith('select:')) input = h('select', {}, h('option', { value: '' }, '(default)'), type.slice(7).split(',').map((o) => h('option', { value: o, selected: v === o }, o)));
|
||
else input = h('input', { type: type === 'list' ? 'text' : type, value: Array.isArray(v) ? v.join(', ') : (v ?? ''), placeholder: '(default)', autocomplete: 'off' });
|
||
inputs[key] = { input, type, orig: v };
|
||
return type === 'bool'
|
||
? h('label', { class: 'check' }, input, label)
|
||
: h('label', { class: 'field' }, h('span', {}, label), input, help && h('small', {}, help));
|
||
}))));
|
||
put(card,
|
||
h('p', { class: 'small muted' }, 'File: ', h('code', {}, cfg.file)),
|
||
...sections,
|
||
h('div', { class: 'row' }, h('button', { class: 'btn primary', onclick: save }, 'Save')));
|
||
|
||
async function save() {
|
||
const patch = {};
|
||
for (const [key, { input, type, orig }] of Object.entries(inputs)) {
|
||
let v;
|
||
if (type === 'bool') v = input.checked;
|
||
else if (type === 'number') v = input.value === '' ? null : Number(input.value);
|
||
else if (type === 'list') v = input.value.split(',').map((s) => s.trim()).filter(Boolean);
|
||
else v = input.value.trim() || null;
|
||
const same = JSON.stringify(v ?? null) === JSON.stringify(orig ?? null) || (type === 'list' && !v.length && orig == null) || (type === 'bool' && !v && orig == null);
|
||
if (!same) patch[key] = v;
|
||
}
|
||
if (!Object.keys(patch).length) return toast('Nothing changed');
|
||
try {
|
||
const r = await api('PUT', '/api/config', patch);
|
||
for (const k of Object.keys(patch)) inputs[k].orig = patch[k];
|
||
if (r.restartNeeded) {
|
||
if (await confirmModal('Restart s3d?', 'The new settings apply after a restart.', 'Restart now', 'primary')) await daemonAction('restart');
|
||
} else toast('Saved');
|
||
refreshStatus();
|
||
} catch (e) { fail(e); }
|
||
}
|
||
})();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- logs
|
||
|
||
function logs(main) {
|
||
const box = h('div', { class: 'logs' });
|
||
let follow = true;
|
||
box.addEventListener('scroll', () => { follow = box.scrollTop + box.clientHeight >= box.scrollHeight - 20; });
|
||
main.append(
|
||
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Logs'), h('p', { class: 'muted' }, 'Output from the s3d process Pithos started.')),
|
||
h('button', { class: 'btn', onclick: () => copy(state.logs.map((l) => l.line).join('\n'), 'Logs copied') }, 'Copy all')),
|
||
box);
|
||
const line = (l) => h('div', { class: l.stream }, l.line);
|
||
box.append(...state.logs.map(line));
|
||
if (!state.logs.length) box.append(h('div', { class: 'sys' }, 'No output yet.'));
|
||
box.scrollTop = box.scrollHeight;
|
||
logSink = (l) => {
|
||
box.append(line(l));
|
||
while (box.childElementCount > 2000) box.firstChild.remove();
|
||
if (follow) box.scrollTop = box.scrollHeight;
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------- boot
|
||
|
||
function showSignin() {
|
||
$('#app').hidden = true;
|
||
$('#signin').hidden = false;
|
||
}
|
||
|
||
async function boot() {
|
||
const s = await api('GET', '/api/session');
|
||
if (!s.authenticated) {
|
||
showSignin();
|
||
if (!s.passwordLogin) {
|
||
$('#signin-hint').textContent = 'Open Pithos with the sign-in link printed when it started.';
|
||
$('#signin-form').querySelector('input').hidden = true;
|
||
$('#signin-form').querySelector('button').hidden = true;
|
||
}
|
||
return;
|
||
}
|
||
$('#signin').hidden = true;
|
||
$('#app').hidden = false;
|
||
// Backfill logs the server already holds, then stream.
|
||
try {
|
||
state.logs = await api('GET', '/api/logs');
|
||
state.logSeq = state.logs.at(-1)?.seq || 0;
|
||
} catch {}
|
||
await refreshStatus();
|
||
connectEvents();
|
||
window.addEventListener('hashchange', route);
|
||
route();
|
||
setInterval(refreshStatus, 15000);
|
||
}
|
||
|
||
$('#signin-form').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const err = $('#signin-error');
|
||
err.hidden = true;
|
||
try {
|
||
await api('POST', '/api/session', { password: $('#signin-password').value });
|
||
location.reload();
|
||
} catch (ex) { err.textContent = ex.message; err.hidden = false; }
|
||
});
|
||
|
||
boot().catch(fail);
|