theseus/bundled-addons/pithos/ui/app.js
Local Dev 8186407b61 Theseus: bundle Pithos 0.2.0 (sidebar panel)
Rebuilt from Pithos/ with scripts/build-theseus-addon.mjs. Pithos moves from a toolbar menu that opened a loopback tab to a left-sidebar panel. Existing installs get the same version over the extension update channel.
2026-10-03 19:34:55 +02:00

973 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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));
}
// Inside the Theseus sidebar the page is the add-on's own file, with no
// server origin to fetch from: every call goes over the add-on IPC bridge
// and the add-on forwards it to the control server.
const bridge = window.silentmode && typeof window.silentmode.invoke === 'function' ? window.silentmode : null;
async function api(method, path, body, { raw } = {}) {
if (bridge) {
const r = await bridge.invoke('api', { method, path, body });
const data = r.data || {};
if (r.status >= 400) throw new Error(data.error || `HTTP ${r.status}`);
return data;
}
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); }
}
const eventHandlers = {
status(d) {
if (!state.status) return;
state.status.daemon = d;
renderPill();
emit();
},
log(l) {
if (l.seq <= state.logSeq) return;
state.logSeq = l.seq;
state.logs.push(l);
if (state.logs.length > 2000) state.logs.shift();
logSink?.(l);
},
login(d) { state.login = d; emit(); },
install(p) { if (p.phase === 'extract') toast('Verified checksum, installing…'); },
};
function connectEvents() {
if (bridge) {
bridge.on('pithos-event', ({ event, data }) => eventHandlers[event]?.(data));
bridge.invoke('events-start').catch(fail);
return;
}
const es = new EventSource('/api/events');
for (const [name, fn] of Object.entries(eventHandlers)) es.addEventListener(name, (e) => fn(JSON.parse(e.data)));
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) {
if (bridge) { try { await bridge.invoke('open-external', { url }); } catch (e) { fail(e); } return; }
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: '',
// The sidebar page has no server origin; Theseus's download
// manager fetches the file through a one-time link instead.
onclick: bridge ? (e) => { e.preventDefault(); bridge.invoke('download', { path: `${base}/object?key=${encodeURIComponent(o.key)}` }).then(() => toast('Download started')).catch(fail); } : null,
}, 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);
if (bridge) {
// The whole file crosses the IPC bridge as one buffer, so keep it to
// sizes a renderer can hold comfortably; S3 clients have no limit.
if (file.size > 1024 ** 3) { row.remove(); toast(`${file.name}: uploads from the sidebar are limited to 1 GiB; use an S3 client for larger files`, 'error'); continue; }
pct.textContent = 'uploading…';
bar.style.width = '100%';
bar.style.opacity = '.4';
file.arrayBuffer()
.then((bytes) => bridge.invoke('upload', { path: `${base}/object?key=${encodeURIComponent(key)}`, bytes, type: file.type }))
.then((r) => {
if (r.status >= 200 && r.status < 300) { pct.textContent = 'done'; bar.style.opacity = '1'; setTimeout(() => row.remove(), 1500); load(); }
else { pct.textContent = 'failed'; pct.className = 'error'; toast(`${file.name}: ${r.data?.error || `HTTP ${r.status}`}`, 'error'); }
})
.catch((e) => { pct.textContent = 'failed'; pct.className = 'error'; toast(`${file.name}: ${e.message}`, 'error'); });
continue;
}
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
// Theseus sidebar: the panel starts narrow; bucket tables want room, so offer
// the host's widen/restore, plus a regular tab for anyone who prefers one.
function addSidebarControls() {
const row = h('div', { class: 'host-controls' });
if (bridge.sidebar?.toggleMax) {
const widen = h('button', { class: 'btn small', title: 'Widen the sidebar', onclick: () => bridge.sidebar.toggleMax() }, '⤢ Widen');
const label = (max) => { widen.textContent = max ? '⤡ Narrow' : '⤢ Widen'; widen.title = max ? 'Back to the normal sidebar width' : 'Widen the sidebar'; };
bridge.sidebar.isMax?.().then(label).catch(() => {});
bridge.sidebar.onMaxChange?.(label);
row.append(widen);
}
row.append(h('button', { class: 'btn small', title: 'Open Pithos in a browser tab', onclick: () => bridge.invoke('open-in-tab').catch(fail) }, 'Open in tab'));
$('.sidebar').insertBefore(row, $('#daemon-pill'));
}
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;
if (bridge) addSidebarControls();
// 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);