diff --git a/bundled-addons/pithos/addon.json b/bundled-addons/pithos/addon.json index 6b4d703b..a67efa5b 100644 --- a/bundled-addons/pithos/addon.json +++ b/bundled-addons/pithos/addon.json @@ -1,7 +1,7 @@ { "id": "pithos", "name": "Pithos", - "version": "0.3.12", + "version": "0.3.13", "description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.", "author": "Silent Mode", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=", diff --git a/bundled-addons/pithos/core/daemon.js b/bundled-addons/pithos/core/daemon.js index 6fd4235a..2c1c3344 100644 --- a/bundled-addons/pithos/core/daemon.js +++ b/bundled-addons/pithos/core/daemon.js @@ -62,12 +62,24 @@ export class Daemon extends EventEmitter { logsSince(seq = 0) { return this.logs.filter((l) => l.seq > seq); } + // s3d migrates its database when it starts, and so does every one-shot + // command (users list, keys…) that opens it. Two of them at once failed + // with "table users already exists", so a start waits for running + // commands to finish, and commands wait while s3d is starting. start() { if (!this.binary) throw new Error('s3d binary not found'); - if (this.child) return this.status(); + if (this.child || this.pendingStart) return this.status(); this.exitCode = null; this.needsLogin = false; this.setState('starting'); + if (this.cliRuns > 0) { + this.pendingStart = true; + this.once('cli-idle', () => { this.pendingStart = false; if (this.state === 'starting' && !this.child) this.spawnServer(); }); + } else this.spawnServer(); + return this.status(); + } + + spawnServer() { const child = spawn(this.binary, [], { cwd: this.cwd(), env: this.env(), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); @@ -91,6 +103,7 @@ export class Daemon extends EventEmitter { async stop() { const child = this.child; + if (!child && this.pendingStart) { this.pendingStart = false; this.setState('stopped'); return this.status(); } if (!child) return this.status(); this.setState('stopping'); const exited = new Promise((r) => child.once('exit', r)); @@ -103,15 +116,32 @@ export class Daemon extends EventEmitter { // One-shot CLI run. Resolves { code, stdout, stderr }; never rejects on a // non-zero exit so callers can surface s3d's own error text. - run(args, { timeout = 30_000 } = {}) { - if (!this.binary) return Promise.reject(new Error('s3d binary not found')); - return new Promise((resolve, reject) => { - execFile(this.binary, args, { - cwd: this.cwd(), env: this.env(), timeout, windowsHide: true, maxBuffer: 16 << 20, - }, (err, stdout, stderr) => { - if (err && typeof err.code !== 'number') return reject(err); - resolve({ code: err ? err.code : 0, stdout: stdout.replace(ANSI, ''), stderr: stderr.replace(ANSI, '') }); + async run(args, { timeout = 30_000 } = {}) { + if (!this.binary) throw new Error('s3d binary not found'); + if (this.state === 'starting') await this.settled(20_000); + this.cliRuns = (this.cliRuns || 0) + 1; + try { + return await new Promise((resolve, reject) => { + execFile(this.binary, args, { + cwd: this.cwd(), env: this.env(), timeout, windowsHide: true, maxBuffer: 16 << 20, + }, (err, stdout, stderr) => { + if (err && typeof err.code !== 'number') return reject(err); + resolve({ code: err ? err.code : 0, stdout: stdout.replace(ANSI, ''), stderr: stderr.replace(ANSI, '') }); + }); }); + } finally { + if (--this.cliRuns === 0) this.emit('cli-idle'); + } + } + + // Resolves once s3d is no longer starting (or after ms). + settled(ms) { + return new Promise((resolve) => { + if (this.state !== 'starting') return resolve(); + const done = () => { clearTimeout(t); this.off('status', on); resolve(); }; + const on = (st) => { if (st.state !== 'starting') done(); }; + const t = setTimeout(done, ms); + this.on('status', on); }); } } diff --git a/bundled-addons/pithos/core/server.js b/bundled-addons/pithos/core/server.js index 1e86672b..8855e9da 100644 --- a/bundled-addons/pithos/core/server.js +++ b/bundled-addons/pithos/core/server.js @@ -340,7 +340,9 @@ export async function createPithos(opts = {}) { const autoFlush = createAutoFlush({ settings: () => { const p = readPrefs(configFile); - return { enabled: p.autoFlush === true, minutes: p.autoFlushMinutes ?? DEFAULT_MINUTES }; + // On unless the user turned it off: files left in the buffer otherwise + // never reach Sia, and nobody expects to have to push them. + return { enabled: p.autoFlush !== false, minutes: p.autoFlushMinutes ?? DEFAULT_MINUTES }; }, // Only an s3d that is up can be asked; a stopped one just means no flush. stats: () => (daemon.state === 'starting' || daemon.state === 'stopping' diff --git a/bundled-addons/pithos/ui/app.css b/bundled-addons/pithos/ui/app.css index bf119f44..95548062 100644 --- a/bundled-addons/pithos/ui/app.css +++ b/bundled-addons/pithos/ui/app.css @@ -421,3 +421,24 @@ details.more-opts > summary:hover { color: var(--text); } .space { margin-top: 6px; max-width: 420px; } .space-bar { margin-top: 4px; height: 6px; } .space-bar.full > i { background: var(--warn); } + +/* Navigation: the Accounts card first, then pages, then the s3d group */ +nav a.nav-account { display: flex; flex-direction: column; gap: 2px; padding: 9px 10px; margin-bottom: 6px; + border: 1px solid var(--border); border-radius: 10px; background: var(--surface-2); min-width: 0; } +nav a.nav-account:hover { border-color: var(--accent); background: var(--surface-2); } +nav a.nav-account.active { border-color: var(--accent); background: var(--accent-soft); color: var(--text); font-weight: 400; } +.na-label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); } +nav a.nav-account.active .na-label { color: var(--accent); } +.na-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.na-space { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); margin-top: 2px; } +.na-space .bar { height: 4px; } +.na-space .bar.full > i { background: var(--warn); } +.nav-group { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); padding: 12px 10px 4px; } + +/* Full page / Back to sidebar as an icon beside the menu button (compact bar) */ +.bar-btn { display: none; } +@media (max-width: 760px) { + .bar-btn { display: inline-grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 8px; + background: var(--surface); color: var(--text); font: inherit; font-size: 16px; cursor: pointer; } + .bar-btn:hover { border-color: var(--accent); color: var(--accent); } +} diff --git a/bundled-addons/pithos/ui/app.js b/bundled-addons/pithos/ui/app.js index f84a0c4e..00bbf0e5 100644 --- a/bundled-addons/pithos/ui/app.js +++ b/bundled-addons/pithos/ui/app.js @@ -238,7 +238,8 @@ function route() { for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === navView); // Compact bar (narrow hosts): name the page, fold the menu away. const here = document.querySelector(`#nav a[data-view="${navView}"]`); - $('#here').textContent = here ? here.textContent : ''; + $('#here').textContent = here ? (here.dataset.title || here.textContent) : ''; + refreshNavAccount(); setMenu(false); const main = $('#main'); put(main); @@ -254,6 +255,7 @@ function overview(main) { 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, + pendingBanner(), h('div', { class: 'grid two' }, daemonCard, statsCard), ); @@ -729,6 +731,7 @@ function otherAccounts(after) { toast(`Switched to ${name}`); await refreshStatus(); await loadAccount(); + refreshNavAccount(true); after(); } catch (e) { fail(e); } } @@ -767,7 +770,7 @@ function setup(main) { h('p', {}, l.indexerUrl ? ['This s3d uses a Sia account on ', h('code', {}, l.indexerUrl), '. All S3 users, drives and files on it are stored, and paid for, through that account.'] : 'This s3d is registered with an indexer.'), h('label', { class: 'field', style: 'max-width:420px' }, h('span', {}, 'Account label'), h('div', { class: 'row', style: 'flex-wrap:nowrap' }, label, - h('button', { class: 'btn', onclick: async () => { try { await api('PUT', '/api/account/label', { label: label.value }); await loadAccount(); toast('Label saved'); } catch (e) { fail(e); } } }, 'Save')), + h('button', { class: 'btn', onclick: async () => { try { await api('PUT', '/api/account/label', { label: label.value }); await loadAccount(); refreshNavAccount(true); toast('Label saved'); } catch (e) { fail(e); } } }, 'Save')), h('small', {}, 'Pithos cannot see the login email, so name it here: the email you approved the connection with is a good label.')), siaAccountBlock(accountInfo, async () => { try { accountInfo = await api('GET', '/api/account?fresh=1'); draw(); } catch (e) { fail(e); } }), h('div', { class: 'row', style: 'margin-top:16px' }, @@ -863,6 +866,7 @@ function setup(main) { toast(`Previous account set aside in ${r.archived}`); await refreshStatus(); await loadAccount(); + refreshNavAccount(true); Object.assign(wiz, { step: 1, accountReady: false, openedUrl: null, key: null }); navTo('#/start'); } catch (e) { fail(e); } @@ -1282,6 +1286,50 @@ function siaAccountBlock(a, onRefresh) { 'The same phrase approved from two different logins makes two different accounts; the same phrase from the same login is the same account, even on another computer.')); } +// "N files are waiting to upload to Sia" with Upload now. s3d holds small +// uploads until about 40 MB has built up; until then they exist only on this +// computer, which is easy to miss. +function pendingBanner() { + const el = h('div', { hidden: true }); + const st = daemonState(); + if (st !== 'running' && st !== 'external') return el; + Promise.all([api('GET', '/api/stats'), api('GET', '/api/autoflush').catch(() => null)]).then(([x, af]) => { + if (!x?.pendingObjects) return; + const n = x.pendingObjects; + const when = af?.enabled + ? (af.dueAt ? `They go up by themselves in about ${Math.max(1, Math.ceil((af.dueAt - Date.now()) / 60_000))} min if nothing new arrives.` : `They go up by themselves after ${af.minutes} min with no new uploads.`) + : 'They go up only when about 40 MB has built up, or when you upload them now.'; + const btn = h('button', { class: 'btn primary small', onclick: async () => { + btn.disabled = true; put(btn, h('span', { class: 'spinner' }), ' Uploading…'); + try { await api('POST', '/api/flush'); toast('Your files are on Sia'); el.hidden = true; } + catch (e) { fail(e); btn.disabled = false; put(btn, 'Upload now'); } + } }, 'Upload now'); + put(el, h('div', { class: 'banner info' }, + h('span', {}, h('strong', {}, `${n.toLocaleString()} file${n === 1 ? '' : 's'} (${fmtBytes(x.pendingSize)}) waiting to upload to Sia. `), + h('span', { class: 'small' }, `Until then they are only on this computer. ${when}`)), + btn)); + el.hidden = false; + }).catch(() => {}); + return el; +} + +// The Accounts card at the top of the navigation: which Sia account this is +// and how much room it has left. Refreshed at most every 30 s as pages change. +let navAccountAt = 0; +function refreshNavAccount(force) { + if (!force && Date.now() - navAccountAt < 30_000) return; + navAccountAt = Date.now(); + loadAccount().then((a) => { $('#na-name').textContent = a?.registered ? accountName(a) : 'Not connected yet'; }).catch(() => {}); + api('GET', '/api/space').then((s) => { + const el = $('#na-space'); + if (!s?.available || !(s.maxPinnedData > 0)) { el.hidden = true; return; } + const pct = Math.min(100, Math.round((s.pinnedData / s.maxPinnedData) * 100)); + put(el, h('span', {}, `${fmtBytes(s.remainingStorage)} free of ${fmtBytes(s.maxPinnedData)}`), + h('span', { class: `bar${pct >= 90 ? ' full' : ''}` }, h('i', { style: `width:${Math.max(pct, s.pinnedData > 0 ? 2 : 0)}%` }))); + el.hidden = false; + }).catch(() => {}); +} + // Space on the Sia account: "12.4 GiB of 46.6 GiB used · 34.2 GiB free" and a bar. function spaceLine() { const el = h('div', { class: 'space', hidden: true }); @@ -1311,7 +1359,7 @@ async function bucketList(head, body, user, userSel) { h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn primary', onclick: create }, 'New drive'))); const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…')); const bar = h('div', { class: 'view-bar' }); - put(body, bar, card); + put(body, pendingBanner(), bar, card); const base = `/api/s3/${encodeURIComponent(user)}/buckets`; let mode = viewMode('drives', 'icons'); let list = []; @@ -2304,6 +2352,12 @@ function addSidebarControls() { } }, '⤢ Full page')); $('.sidebar').insertBefore(row, $('#daemon-pill')); addMenuAction('⤢ Open as full page', () => row.querySelector('button').click()); + addBarButton('⤢', 'Open as full page', () => row.querySelector('button').click()); +} + +// A one-click icon in the compact top bar, beside ☰ (narrow layouts only). +function addBarButton(icon, label, run) { + $('.sidebar').insertBefore(h('button', { type: 'button', class: 'bar-btn', title: label, 'aria-label': label, onclick: run }, icon), $('#nav-toggle')); } // An action at the end of the ☰ menu (narrow layouts only; wide ones show @@ -2445,6 +2499,7 @@ function addBackToSidebar() { } }, '⇤ Back to sidebar')); $('.sidebar').insertBefore(row, $('#daemon-pill')); addMenuAction('⇤ Back to sidebar', () => row.querySelector('button').click()); + addBarButton('⇤', 'Back to sidebar', () => row.querySelector('button').click()); } function showSignin() { diff --git a/bundled-addons/pithos/ui/index.html b/bundled-addons/pithos/ui/index.html index 40f5f8d8..88fb334c 100644 --- a/bundled-addons/pithos/ui/index.html +++ b/bundled-addons/pithos/ui/index.html @@ -32,10 +32,15 @@