diff --git a/bundled-addons/pithos/addon.json b/bundled-addons/pithos/addon.json index 929e8381..86112c41 100644 --- a/bundled-addons/pithos/addon.json +++ b/bundled-addons/pithos/addon.json @@ -1,7 +1,7 @@ { "id": "pithos", "name": "Pithos", - "version": "0.3.5", + "version": "0.3.6", "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/server.js b/bundled-addons/pithos/core/server.js index ed5a91f5..67590ea3 100644 --- a/bundled-addons/pithos/core/server.js +++ b/bundled-addons/pithos/core/server.js @@ -363,6 +363,17 @@ export async function createPithos(opts = {}) { const total = Number((up.headers['content-range'] || '').split('/')[1]) || body.length; return { text: body, truncated: total > 524288, size: total }; }); + // Drive usage: files and bytes in a bucket, counted up to 20 pages. + route('GET', '/api/s3/:user/buckets/:bucket/usage', async (req, url, p) => { + const client = await s3For(p.user); + let files = 0, bytes = 0, token, pages = 0; + do { + const page = await client.listObjects(p.bucket, { delimiter: '', token }); + for (const o of page.objects) { if (!o.key.endsWith('/')) { files++; bytes += o.size || 0; } } + token = page.truncated ? page.nextToken : null; + } while (token && ++pages < 20); + return { files, bytes, partial: !!token }; + }); route('POST', '/api/host/show-panel', () => { if (!showPanel) throw new HttpError(400, 'not supported by this host'); showPanel(); diff --git a/bundled-addons/pithos/ui/app.css b/bundled-addons/pithos/ui/app.css index e334c690..d373e670 100644 --- a/bundled-addons/pithos/ui/app.css +++ b/bundled-addons/pithos/ui/app.css @@ -243,3 +243,10 @@ html, body { overflow-x: hidden; } .viewer-media { display: block; max-width: 100%; max-height: 70vh; margin: 0 auto; border-radius: 8px; background: var(--surface-2); } .viewer-audio { width: 100%; } .viewer-text { max-height: 60vh; white-space: pre-wrap; overflow-wrap: anywhere; } + +/* Drive icon (a bucket): a small disk with a status light */ +.drive-icon { width: 46px; height: 30px; border-radius: 7px; position: relative; + background: linear-gradient(180deg, var(--surface) 0%, var(--surface-2) 100%); + border: 2px solid var(--muted); box-shadow: inset 0 -7px 0 color-mix(in srgb, var(--muted) 25%, transparent); } +.drive-icon::after { content: ""; position: absolute; right: 7px; bottom: 3px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); } +.tile:hover .drive-icon { border-color: var(--accent); } diff --git a/bundled-addons/pithos/ui/app.js b/bundled-addons/pithos/ui/app.js index 74c4b933..5fd16f8b 100644 --- a/bundled-addons/pithos/ui/app.js +++ b/bundled-addons/pithos/ui/app.js @@ -829,7 +829,7 @@ function users(main) { 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(); state.selectedUser = u.name; localStorage.setItem('pithos.user', u.name); location.hash = '#/buckets'; } }, 'Drives'), ' ', h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); newKey(u.name); } }, 'New key'), ' ', @@ -857,7 +857,7 @@ function users(main) { name.addEventListener('input', () => { if (!bucketEdited) bucket.value = suggestBucket(name.value); }); 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: 'field' }, h('span', {}, 'Their bucket'), bucket, h('small', {}, 'Created with the user, like a home folder. Lowercase letters, digits and hyphens. They can make more later.')), + h('label', { class: 'field' }, h('span', {}, 'Their drive'), bucket, h('small', {}, 'Created with the user, like a home drive (an S3 bucket). Lowercase letters, digits and hyphens. They can add more later.')), 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, bucket: bucket.value.trim() || undefined }) }]); if (!v) return; @@ -1072,7 +1072,7 @@ function buckets(main, [bucket, ...prefixParts]) { 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(head, h('div', {}, h('h1', {}, 'Drives'))); 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; } @@ -1080,7 +1080,7 @@ function buckets(main, [bucket, ...prefixParts]) { 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(head, h('div', {}, h('h1', {}, 'Drives'))); 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; } @@ -1119,8 +1119,8 @@ function accountLine() { async function bucketList(head, body, user, userSel) { put(head, - h('div', {}, h('h1', {}, 'Buckets'), h('p', { class: 'muted' }, 'Top-level storage containers, like drives. Each belongs to one S3 user.'), accountLine()), - h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn primary', onclick: create }, 'New bucket'))); + h('div', {}, h('h1', {}, 'Drives'), h('p', { class: 'muted' }, 'Each drive is an S3 bucket: top-level storage owned by one S3 user. S3 apps call it a bucket.'), accountLine()), + 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…')); put(body, card); const base = `/api/s3/${encodeURIComponent(user)}/buckets`; @@ -1128,8 +1128,8 @@ async function bucketList(head, body, user, userSel) { 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; } - // Buckets read as top-level folders. + if (!list.length) { put(card, h('div', { class: 'empty' }, h('p', {}, 'No drives yet.'), h('button', { class: 'btn primary', onclick: create }, 'Create a drive'))); return; } + // Buckets read as drives: the top level you put folders into. put(card, h('div', { class: 'tiles' }, list.map((b) => { const access = h('span', { class: 'badge' }, '…'); api('GET', `${base}/${encodeURIComponent(b.name)}/policy`).then((p) => { @@ -1138,8 +1138,9 @@ async function bucketList(head, body, user, userSel) { return h('div', { class: 'tile', tabindex: '0', role: 'button', onclick: () => { location.hash = `#/buckets/${encodeURIComponent(b.name)}`; }, onkeydown: (e) => { if (e.key === 'Enter') location.hash = `#/buckets/${encodeURIComponent(b.name)}`; } }, - h('div', { class: 'tile-icon' }, '📁'), + h('div', { class: 'drive-icon', 'aria-hidden': 'true' }), h('div', { class: 'tile-name' }, b.name), + usageLine(b.name), h('div', { class: 'tile-meta' }, access), h('div', { class: 'tile-actions' }, h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); accessDialog(user, b.name).then(load); } }, 'Access'), @@ -1148,8 +1149,17 @@ async function bucketList(head, body, user, userSel) { } catch (e) { put(card, h('p', { class: 'error' }, e.message)); } } + // Files and size on a drive, counted in the background. + function usageLine(bucketName) { + const el = h('div', { class: 'small muted' }, 'counting…'); + api('GET', `${base}/${encodeURIComponent(bucketName)}/usage`) + .then((u) => put(el, `${u.files.toLocaleString()}${u.partial ? '+' : ''} file${u.files === 1 ? '' : 's'} · ${fmtBytes(u.bytes)}${u.partial ? '+' : ''}`)) + .catch(() => put(el, '')); + return el; + } + 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 name = h('input', { type: 'text', required: true, pattern: '[a-z0-9][a-z0-9.\\-]{1,61}[a-z0-9]', placeholder: 'my-drive' }); 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; @@ -1193,7 +1203,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { 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: () => { location.hash = '#/buckets'; } }, 'Drives'), 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)])); diff --git a/bundled-addons/pithos/ui/index.html b/bundled-addons/pithos/ui/index.html index 3cf1b7da..dbf414de 100644 --- a/bundled-addons/pithos/ui/index.html +++ b/bundled-addons/pithos/ui/index.html @@ -34,7 +34,7 @@