diff --git a/docs/user/web.md b/docs/user/web.md index 7b3598a5..e9612dcd 100644 --- a/docs/user/web.md +++ b/docs/user/web.md @@ -247,12 +247,17 @@ conversation. ### Settings -The same pages as [`/settings`](/user/settings), in the same order, and what the epics spent: +Settings has two groups. **App** is the page's own: how it is drawn, the daemons it reaches and +what it tells you of them. It is the same whichever daemon is on screen. **Daemon** is the same +pages as [`/settings`](/user/settings), in the same order, and what the epics spent, all of one +daemon: | Page | What is there | | --- | --- | -| **General** | Whether a turn's working is shown, the [`/btw` agent](/user/btw), and [error reports](/user/reporting) | | **Themes** | The [themes](#themes) of this browser, and which two **Auto** follows your system with | +| **Daemons** | The daemons this page reaches: **Local**, and each SSH host added or imported | +| **Notifications** | Which notifications appear, from every daemon, and whether the system shows them | +| **General** | Whether a turn's working is shown, the [`/btw` agent](/user/btw), and [error reports](/user/reporting) | | **Accounts** | Every [account](/user/settings#accounts) by its CLI, way in and the names of what it sets: ask what it runs again, **Edit** it (what you leave blank stays as it was), copy it to another CLI, remove it, or make one | | **Fallback** | Each [step](/user/settings#fallback): where a turn goes when a place cannot take it, and how it is tried again | | **Runtimes** | Every runtime: add one from its backend's fields, change, check or remove it, or bring hosts in from your ssh config | @@ -260,6 +265,20 @@ The same pages as [`/settings`](/user/settings), in the same order, and what the | **Workspace** | What a directory was last set up with, and forgetting it -- which also takes it out of the sidebar where no epic ran there | | **Usage** | What the epics spent, of every directory or of one, by day and by flow, looking back 7, 30, 90 or 365 days | +Above the **Daemon** pages, **Daemon** picks which daemon they read and write: the one on screen +until another is picked. It goes into the address as `?daemon=`, so a daemon's page can be +kept and opened again. Picking one changes nothing else on screen. + +**Batch apply**, on **General**, **Fallback**, **Runtimes** and **Backup**, copies what the page +holds to other daemons at once. Tick the daemons, or **All**, then **Apply**. Each daemon then +says **Applied** or why it refused. **General** sends the form as it stands, saved or not. +**Backup** sends every target and default, in place of the daemon's own. A target signed into its URL +is refused there, since the URL is never shown whole. **Fallback** and +**Runtimes** send each step or runtime, written over the one of the same name there, or added. +Nothing a daemon already has is taken away. **Accounts** cannot be copied, since their secrets +are never shown (**Secrets stay put**). **Workspace** and **Usage** are of one daemon's +directories, and have no **Batch apply**. + An account is never shown by its values: a page lists the names of the variables it sets, and nothing else. A way in that runs a CLI's own sign-in, such as `login`, needs a terminal: it is offered as `(at a terminal)` and made from `/settings` in `hmz`. diff --git a/src/hmz/web/static/paths.js b/src/hmz/web/static/paths.js index ccae0649..2fed069c 100644 --- a/src/hmz/web/static/paths.js +++ b/src/hmz/web/static/paths.js @@ -14,8 +14,9 @@ import { follow } from './shell.js' * @param {string} [made.placeholder] * @param {string} [made.id] * @param {(path: string) => void} [made.changed] Told the path whenever it changes. + * @param {string} [made.from] Whose paths: '' for the daemon the page is on, else `/d/`. */ -export function pathPicker({ value = '', files = false, label, placeholder = files ? '~/file' : '~/folder', id, changed = () => {} }) { +export function pathPicker({ value = '', files = false, label, placeholder = files ? '~/file' : '~/folder', id, changed = () => {}, from = '' }) { const list = `paths-${Math.random().toString(36).slice(2, 8)}` const input = h('input', { type: 'text', value, placeholder, 'aria-label': label, id, list, autocomplete: 'off', spellcheck: 'false', 'data-hmz': 'path-input' }) const offered = h('datalist', { id: list }) @@ -37,7 +38,7 @@ export function pathPicker({ value = '', files = false, label, placeholder = fil clearTimeout(typed) typed = setTimeout(async () => { try { - const { paths } = await get(`/api/paths?typed=${encodeURIComponent(input.value)}`) + const { paths } = await get(`${from}/api/paths?typed=${encodeURIComponent(input.value)}`) fill(offered, paths.map((one) => h('option', { value: one }))) } catch { fill(offered) @@ -48,7 +49,7 @@ export function pathPicker({ value = '', files = false, label, placeholder = fil async function show(dir) { let read try { - read = await get(`/api/files?at=${encodeURIComponent(dir)}${files ? '&files=1' : ''}${hidden ? '&hidden=1' : ''}`) + read = await get(`${from}/api/files?at=${encodeURIComponent(dir)}${files ? '&files=1' : ''}${hidden ? '&hidden=1' : ''}`) } catch (error) { if (dir !== '~') return show('~') fill(panel, h('p', { class: 'error' }, error.message)) diff --git a/src/hmz/web/static/pickers.js b/src/hmz/web/static/pickers.js index c875a906..2c124775 100644 --- a/src/hmz/web/static/pickers.js +++ b/src/hmz/web/static/pickers.js @@ -17,10 +17,13 @@ const failed = new Map() /** Reads the CLIs installed again -- as a workspace runs them -- and tells every picker. */ let reading = '' +/** Whose CLIs they are: '' for the daemon the page is on, else `/d/`, as Settings picks. */ +let base = '' -export async function readBackends(workspace = reading) { +export async function readBackends(workspace = reading, from = '') { reading = workspace - backends = await get(`/api/backends?workspace=${encodeURIComponent(workspace)}`) + base = from + backends = await get(`${from}/api/backends?workspace=${encodeURIComponent(workspace)}`) for (const one of told) one() return backends } @@ -31,12 +34,12 @@ function ask(cli, account) { if (!asking.has(key)) { asking.set( key, - post('/api/accounts/models', { cli, name: account }) + post(`${base}/api/accounts/models`, { cli, name: account }) .then(() => failed.delete(key)) .catch((error) => failed.set(key, error.message)) .finally(() => { asking.delete(key) - return readBackends() + return readBackends(reading, base) }), ) } diff --git a/src/hmz/web/static/style.css b/src/hmz/web/static/style.css index fecebe47..99cfb863 100644 --- a/src/hmz/web/static/style.css +++ b/src/hmz/web/static/style.css @@ -3845,3 +3845,70 @@ input[aria-invalid='true'] { -webkit-user-select: none; user-select: none; } + +/* settings daemons */ +.settings-group { + margin: 12px 10px 4px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ink-3); +} + +.settings-nav h2 + .settings-group { + margin-top: 0; +} + +.settings-daemon { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + border-bottom: 1px solid var(--line); +} + +.settings-daemon select { + min-width: 0; + max-width: 100%; +} + +.batch-dialog { + width: min(calc(100vw - 32px), 420px); + max-height: calc(100vh - 32px); + overflow-y: auto; + padding: 16px 20px; + color: var(--ink); + background: var(--paper); + border: 2px solid var(--ink); + box-shadow: 8px 8px 0 var(--cyan); +} + +.batch-dialog::backdrop { + background: var(--scrim); +} + +.batch-dialog h3 { + margin: 0 0 4px; +} + +.batch-results { + margin: 12px 0; + padding: 0; + list-style: none; + display: grid; + gap: 4px; + overflow-wrap: anywhere; +} + +@media (max-width: 860px) { + .settings-group { + margin: 0 4px 0 8px; + align-self: center; + white-space: nowrap; + } + + .settings-daemon { + padding: 8px 12px; + } +} diff --git a/src/hmz/web/static/views/settings.js b/src/hmz/web/static/views/settings.js index b8480585..25f8e10d 100644 --- a/src/hmz/web/static/views/settings.js +++ b/src/hmz/web/static/views/settings.js @@ -1,59 +1,241 @@ // What humanize remembers, as ChatGPT keeps its settings: a dialog over the page with its pages -// down the side, in the order the terminal interface's `/settings` lists them -- what is true -// wherever humanize runs, who its agents are and what takes over when one fails, where their -// work goes, where epics are backed up, this one directory -- and last what the runs here spent; and, after what is true -// wherever humanize runs, how this browser draws the page. +// down the side, in two groups. The app's own -- how this page is drawn, which daemons it +// reaches and what it tells you of them -- are this browser's, or this machine's, whichever +// daemon is on screen. The daemon's -- what is true wherever humanize runs, who its agents are +// and what takes over when one fails, where their work goes, where epics are backed up, one +// directory, and last what the runs there spent -- are read from and written to the daemon +// picked above them, the one on screen unless another is, and each page that saves can copy +// what it holds to other daemons at once. -import { shut } from '../app.js' +import * as api from '../api.js' +import { shut, stays } from '../app.js' import { fill, h, iconButton, problem } from '../dom.js' import { dragged } from '../shell.js' -/** Every page, by the word in the address that opens it. */ +/** Every page, by the word in the address that opens it, in its group. */ const PAGES = { - general: { label: 'General', about: 'Display, side questions and reporting', load: () => import('./settings/general.js') }, - themes: { label: 'Themes', about: 'Colours and pictures of this browser’s page', load: () => import('./settings/themes.js') }, - accounts: { label: 'Accounts', about: 'Credentials agents run as', load: () => import('./settings/accounts.js') }, - fallback: { label: 'Fallback', about: 'Where a failed turn goes next', load: () => import('./settings/fallback.js') }, - runtimes: { label: 'Runtimes', about: 'Machines environments run on', load: () => import('./settings/runtimes.js') }, - backup: { label: 'Backup', about: 'Git repositories epics go to', load: () => import('./settings/backup.js') }, - workspace: { label: 'Workspace', about: 'This directory’s saved setup', load: () => import('./settings/workspace.js') }, - usage: { label: 'Usage', about: 'Spending by day and flow', load: () => import('./settings/usage.js') }, + themes: { group: 'app', label: 'Themes', about: 'Colours and pictures of this browser’s page', load: () => import('./settings/themes.js') }, + // Each only where its module is there to load. + daemons: { group: 'app', optional: true, label: 'Daemons', about: 'Machines this page reaches', load: () => import('./settings/daemons.js') }, + notifications: { group: 'app', optional: true, label: 'Notifications', about: 'What every daemon tells you', load: () => import('./settings/notifications.js') }, + general: { group: 'daemon', label: 'General', about: 'Display, side questions and reporting', load: () => import('./settings/general.js') }, + accounts: { group: 'daemon', label: 'Accounts', about: 'Credentials agents run as', load: () => import('./settings/accounts.js') }, + fallback: { group: 'daemon', label: 'Fallback', about: 'Where a failed turn goes next', load: () => import('./settings/fallback.js') }, + runtimes: { group: 'daemon', label: 'Runtimes', about: 'Machines environments run on', load: () => import('./settings/runtimes.js') }, + backup: { group: 'daemon', label: 'Backup', about: 'Git repositories epics go to', load: () => import('./settings/backup.js') }, + workspace: { group: 'daemon', label: 'Workspace', about: 'This directory’s saved setup', load: () => import('./settings/workspace.js') }, + usage: { group: 'daemon', label: 'Usage', about: 'Spending by day and flow', load: () => import('./settings/usage.js') }, +} + +const GROUPS = [ + ['app', 'App'], + ['daemon', 'Daemon'], +] + +/** The daemon the page is on, where the page knows of more than one. */ +function current() { + return (typeof api.daemon === 'function' && api.daemon()) || 'local' +} + +/** Where a daemon's API is reached from here: as the page reaches it, or through this server. */ +function prefix(name) { + return name === current() ? '' : `/d/${encodeURIComponent(name)}` +} + +/** What a daemon is called on screen. */ +function called(one) { + return one.label || (one.name === 'local' ? 'Local' : one.name) +} + +/** Every daemon this page reaches: Local alone where this server keeps no list. */ +async function daemons() { + try { + const answer = await api.get('/api/daemons') + const listed = Array.isArray(answer) ? answer : answer.daemons + if (Array.isArray(listed) && listed.length) return listed + } catch { + // An older server, or none of its own: Local is all there is. + } + return [{ name: 'local' }] } export function mount(root, { parts, query }) { const name = parts[1] in PAGES ? parts[1] : 'general' const page = PAGES[name] - const body = h('div', { class: 'settings-body', 'data-hmz': `settings-${name}` }, h('p', { class: 'loading', role: 'status' }, 'Reading the settings…')) + let body = h('div', { class: 'settings-body', 'data-hmz': `settings-${name}` }, h('p', { class: 'loading', role: 'status' }, 'Reading the settings…')) + const links = {} + let chosen = query.get('daemon') || current() + const href = (key) => `#/settings/${key}${PAGES[key].group === 'daemon' && query.get('daemon') ? `?daemon=${encodeURIComponent(chosen)}` : ''}` + const nav = h( + 'nav', + { class: 'settings-nav', 'aria-label': 'Settings pages' }, + h('h2', {}, 'Settings'), + GROUPS.map(([group, label]) => [ + h('h4', { class: 'settings-group' }, label), + Object.entries(PAGES) + .filter(([, one]) => one.group === group) + .map(([key, one]) => (links[key] = h('a', { href: href(key), 'aria-current': key === name ? 'page' : null, hidden: Boolean(one.optional) && key !== name }, one.label))), + ]), + ) + const picker = h('select', { 'aria-label': 'Daemon', 'data-hmz': 'settings-daemon' }, h('option', { value: chosen }, called({ name: chosen }))) + const head = h('div', { class: 'settings-head' }, h('div', {}, h('h3', {}, page.label), h('p', { class: 'dim' }, page.about)), iconButton('close', 'Close', { onclick: shut })) root.classList.add('dialog-settings') root.setAttribute('aria-label', 'Settings') fill( root, // The top row of a narrow window, which it fills. - dragged( - h( - 'nav', - { class: 'settings-nav', 'aria-label': 'Settings pages' }, - h('h2', {}, 'Settings'), - Object.entries(PAGES).map(([key, one]) => h('a', { href: `#/settings/${key}`, 'aria-current': key === name ? 'page' : null }, one.label)), - ), - ), + dragged(nav), h( 'div', { class: 'settings-page' }, - h('div', { class: 'settings-head' }, h('div', {}, h('h3', {}, page.label), h('p', { class: 'dim' }, page.about)), iconButton('close', 'Close', { onclick: shut })), + head, + page.group === 'daemon' ? h('div', { class: 'settings-daemon' }, h('span', { class: 'label' }, 'Daemon'), picker) : null, body, ), ) + + // Each optional page offered once it is known to load. + for (const [key, one] of Object.entries(PAGES)) { + if (one.optional && key !== name) one.load().then(() => (links[key].hidden = false), () => {}) + } + + let known = [{ name: 'local' }] let leaving = null let gone = false - page - .load() - .then((module) => { - if (!gone) leaving = module.draw(body, query, parts) || null + let drawn = 0 + + /** What every page on the daemon picked reads, writes and copies with. */ + const scope = () => { + const base = prefix(chosen) + return { + name: chosen, + base, + at: (path) => `${base}${path}`, + // What a page puts in the address beside its own, so the daemon picked stays in it. + query: query.get('daemon') ? { daemon: chosen } : {}, + batch: (posts, { off = '' } = {}) => batchButton(root, chosen, () => known, posts, off), + } + } + + const draw = () => { + const mine = ++drawn + if (leaving) leaving() + leaving = null + // A body of its own: what the last daemon's page reads late is drawn where nobody sees it. + const fresh = body.cloneNode(false) + body.replaceWith(fresh) + body = fresh + fill(fresh, h('p', { class: 'loading', role: 'status' }, 'Reading the settings…')) + page + .load() + .then((module) => { + if (!gone && mine === drawn) leaving = module.draw(fresh, query, parts, scope()) || null + }) + .catch((error) => fill(fresh, problem(error))) + } + + if (page.group === 'daemon') { + daemons().then((listed) => { + if (gone) return + known = listed + if (!known.some((one) => one.name === chosen)) known = [...known, { name: chosen }] + fill(picker, known.map((one) => h('option', { value: one.name, selected: one.name === chosen }, called(one)))) }) - .catch((error) => fill(body, problem(error))) + picker.addEventListener('change', () => { + chosen = picker.value + // Kept in the address, so that this daemon's page is one that can be kept; what the + // page said of the last daemon's directories says nothing of this one's. + query = new URLSearchParams({ daemon: chosen }) + stays({ daemon: chosen }) + for (const key of Object.keys(PAGES)) links[key].href = href(key) + draw() + }) + } + draw() return () => { gone = true if (leaving) leaving() + for (const one of root.querySelectorAll('dialog')) one.close() } } + +/** + * Copies what a page holds to other daemons: a dialog of every daemon but the one it came from, + * ticked as they are wanted, and what each said once it was sent there. + * + * @param {HTMLElement} root Where the dialog is drawn. + * @param {string} from The daemon the page holds what it holds of. + * @param {() => {name: string}[]} known Every daemon there is, as last read. + * @param {(() => Array<[string, object]>) | null} posts What is sent: each address and body. + * @param {string} off Why nothing can be, where nothing can. + */ +function batchButton(root, from, known, posts, off) { + const button = h('button', { class: 'btn quiet', type: 'button', 'data-hmz': 'batch-apply', disabled: Boolean(off) }, 'Batch apply') + if (off) return h('span', { class: 'row' }, button, h('span', { class: 'hint', 'data-hmz': 'batch-off' }, off)) + button.addEventListener('click', () => { + const others = known().filter((one) => one.name !== from) + const boxes = others.map((one) => ({ one, box: h('input', { type: 'checkbox', 'data-daemon': one.name }) })) + const all = h('input', { type: 'checkbox', 'data-hmz': 'batch-all' }) + all.addEventListener('change', () => boxes.forEach(({ box }) => (box.checked = all.checked))) + for (const { box } of boxes) box.addEventListener('change', () => (all.checked = boxes.every((each) => each.box.checked))) + const results = h('ul', { class: 'batch-results', 'data-hmz': 'batch-results', 'aria-live': 'polite' }) + const apply = h('button', { class: 'btn primary', type: 'button', 'data-hmz': 'batch-go' }, 'Apply') + const dialog = h( + 'dialog', + { class: 'batch-dialog', 'data-hmz': 'batch-dialog', 'aria-label': 'Batch apply' }, + h('h3', {}, 'Batch apply'), + h('p', { class: 'dim' }, `From ${called({ name: from })}`), + others.length + ? h('div', { class: 'form' }, h('label', { class: 'check' }, all, h('b', {}, 'All')), boxes.map(({ one, box }) => h('label', { class: 'check' }, box, called(one)))) + : h('p', { class: 'dim' }, 'No other daemon.'), + results, + h('div', { class: 'row' }, others.length ? apply : null, h('button', { class: 'btn quiet', type: 'button', onclick: () => dialog.close() }, 'Close')), + ) + // Escape closes this dialog, not Settings under it, wherever the focus is. + const escape = (event) => { + if (event.key !== 'Escape') return + event.stopPropagation() + event.preventDefault() + dialog.close() + } + document.addEventListener('keydown', escape, true) + dialog.addEventListener('close', () => { + document.removeEventListener('keydown', escape, true) + dialog.remove() + }) + apply.addEventListener('click', async () => { + const picked = boxes.filter(({ box }) => box.checked).map(({ one }) => one) + if (!picked.length) return + apply.disabled = true + fill(results) + let sent + try { + sent = await posts() + if (!sent.length) throw new Error('Nothing to apply.') + } catch (error) { + fill(results, h('li', {}, problem(error))) + apply.disabled = false + return + } + await Promise.all( + picked.map(async (one) => { + const line = h('li', { 'data-hmz': 'batch-result', 'data-daemon': one.name }, h('b', {}, called(one)), ' ', h('span', { class: 'dim' }, 'Applying…')) + results.append(line) + const base = prefix(one.name) + try { + for (const [path, said] of sent) await api.post(`${base}${path}`, said) + fill(line, h('b', {}, called(one)), ' ', h('span', { class: 'ok' }, 'Applied')) + line.dataset.ok = 'true' + } catch (error) { + fill(line, h('b', {}, called(one)), ' ', h('span', { class: 'bad' }, error.message)) + line.dataset.ok = 'false' + } + }), + ) + apply.disabled = false + }) + root.append(dialog) + dialog.showModal() + }) + return button +} diff --git a/src/hmz/web/static/views/settings/accounts.js b/src/hmz/web/static/views/settings/accounts.js index 87959725..88b82627 100644 --- a/src/hmz/web/static/views/settings/accounts.js +++ b/src/hmz/web/static/views/settings/accounts.js @@ -8,7 +8,7 @@ import { get, post } from '../../api.js' import { acting, ago, confirming, fill, h, problem } from '../../dom.js' -export function draw(holder) { +export function draw(holder, query, parts, scope) { const listing = h('section', { class: 'panel', 'data-hmz': 'accounts' }) const making = h('section', { class: 'panel', 'data-hmz': 'make-account' }) fill( @@ -38,7 +38,7 @@ export function draw(holder) { h( 'div', { class: 'row' }, - acting('Refresh models', async () => shown(await post('/api/accounts/models', { cli: one.cli, name: one.name })), { kind: 'btn small quiet', busy: 'Asking…' }), + acting('Refresh models', async () => shown(await post(scope.at('/api/accounts/models'), { cli: one.cli, name: one.name })), { kind: 'btn small quiet', busy: 'Asking…' }), one.serves.length ? copying(one) : null, one.name ? h('button', { class: 'btn small quiet', type: 'button', onclick: () => correcting(one) }, 'Edit') : null, one.name @@ -47,7 +47,7 @@ export function draw(holder) { ask: `Remove ${one.cli}@${one.name}, credentials and all?`, yes: 'Remove', small: true, - act: async () => shown(await post('/api/accounts/remove', { cli: one.cli, name: one.name })), + act: async () => shown(await post(scope.at('/api/accounts/remove'), { cli: one.cli, name: one.name })), }) : null, ), @@ -56,7 +56,8 @@ export function draw(holder) { ) fill( listing, - h('h2', {}, 'Accounts'), + // Nothing an account holds is ever shown, so nothing of it can be copied elsewhere. + h('header', {}, h('h2', {}, 'Accounts'), scope.batch(null, { off: 'Secrets stay put' })), rows.length ? h( 'div', @@ -74,7 +75,7 @@ export function draw(holder) { 'span', { class: 'row' }, into, - acting('Copy', async () => shown(await post('/api/accounts/copy', { cli: one.cli, name: one.name, into: into.value })), { kind: 'btn small quiet', busy: 'Copying…' }), + acting('Copy', async () => shown(await post(scope.at('/api/accounts/copy'), { cli: one.cli, name: one.name, into: into.value })), { kind: 'btn small quiet', busy: 'Copying…' }), ) } @@ -113,7 +114,7 @@ export function draw(holder) { for (const input of form.querySelectorAll('input[data-env]')) if (input.value) given[input.dataset.env] = input.value save.disabled = true try { - shown(await post('/api/accounts/correct', { cli: one.cli, name: one.name, answers: given })) + shown(await post(scope.at('/api/accounts/correct'), { cli: one.cli, name: one.name, answers: given })) fill(said, h('span', { class: 'dim', role: 'status' }, 'Saved.')) for (const input of form.querySelectorAll('input')) input.value = '' } catch (error) { @@ -203,7 +204,7 @@ export function draw(holder) { for (const input of answers.querySelectorAll('input[data-env]')) if (input.value) given[input.dataset.env] = input.value make.disabled = true try { - shown(await post('/api/accounts', { cli: cli.value, name: name.value.trim(), way: way.value, answers: given })) + shown(await post(scope.at('/api/accounts'), { cli: cli.value, name: name.value.trim(), way: way.value, answers: given })) fill(said, h('span', { class: 'dim', role: 'status' }, `Made ${cli.value}@${name.value.trim()}.`)) name.value = '' for (const input of answers.querySelectorAll('input')) input.value = '' @@ -217,7 +218,7 @@ export function draw(holder) { drawWays() } - get('/api/accounts') + get(scope.at('/api/accounts')) .then((answer) => { shown(answer) drawMaking() diff --git a/src/hmz/web/static/views/settings/backup.js b/src/hmz/web/static/views/settings/backup.js index 1380af93..c118ed94 100644 --- a/src/hmz/web/static/views/settings/backup.js +++ b/src/hmz/web/static/views/settings/backup.js @@ -20,7 +20,7 @@ function whenSaid(one) { return said.length ? said.join(' · ') : 'Manual' } -export function draw(holder) { +export function draw(holder, query, parts, scope) { const listing = h('section', { class: 'panel', 'data-hmz': 'backups' }) const writing = h('section', { class: 'panel', 'data-hmz': 'write-backup' }) fill(holder, listing, writing) @@ -35,7 +35,7 @@ export function draw(holder) { /** Writes every target down, as they are with one change made, and lists them again. */ const save = async (targets, defaults) => { - data = (await post('/api/settings', { backup: { targets, defaults } })).backup + data = (await post(scope.at('/api/settings'), { backup: { targets, defaults } })).backup drawListing() } @@ -85,7 +85,7 @@ export function draw(holder) { ) fill( listing, - h('h2', {}, 'Targets'), + h('header', {}, h('h2', {}, 'Targets'), scope.batch(() => [['/api/settings', { backup: data }]])), rows.length ? h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', {}, 'Repository'), h('th', {}, 'When'), h('th', {}, ''), h('th', {}, ''))), h('tbody', {}, rows))) : h('p', { class: 'dim' }, 'None yet: no epic is backed up.'), @@ -147,7 +147,7 @@ export function draw(holder) { fill(writing, h('h2', {}, editing ? `Edit ${editing.name}` : 'New target'), form) } - get('/api/settings') + get(scope.at('/api/settings')) .then(shown) .catch((error) => fill(listing, problem(error))) } diff --git a/src/hmz/web/static/views/settings/fallback.js b/src/hmz/web/static/views/settings/fallback.js index ded517ce..88218a2b 100644 --- a/src/hmz/web/static/views/settings/fallback.js +++ b/src/hmz/web/static/views/settings/fallback.js @@ -6,7 +6,7 @@ import { get, post } from '../../api.js' import { acting, confirming, fill, h, iconButton, lasting, problem } from '../../dom.js' import { placePicker, readBackends } from '../../pickers.js' -export function draw(holder) { +export function draw(holder, query, parts, scope) { const listing = h('section', { class: 'panel', 'data-hmz': 'fallbacks' }) const writing = h('section', { class: 'panel', 'data-hmz': 'write-fallback' }) const places = h('datalist', { id: 'places' }) @@ -36,14 +36,15 @@ export function draw(holder) { 'div', { class: 'row' }, h('button', { class: 'btn small quiet', type: 'button', onclick: () => edit(one) }, 'Edit'), - confirming({ label: 'Clear', ask: `Let ${one.spec} fall back nowhere again?`, yes: 'Clear', small: true, act: async () => shown(await post('/api/fallbacks/clear', { spec: one.spec })) }), + confirming({ label: 'Clear', ask: `Let ${one.spec} fall back nowhere again?`, yes: 'Clear', small: true, act: async () => shown(await post(scope.at('/api/fallbacks/clear'), { spec: one.spec })) }), ), ), ), ) fill( listing, - h('h2', {}, 'Steps'), + // Each step written over the one of its place there, or added; none there taken away. + h('header', {}, h('h2', {}, 'Steps'), scope.batch(() => data.steps.map(({ spec, to, tries, policy, timeout }) => ['/api/fallbacks', { spec, to, tries, policy, timeout }]))), rows.length ? h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, 'Place'), h('th', {}, 'Tried'), h('th', {}, 'Then'), h('th', {}, ''))), h('tbody', {}, rows))) : h('p', { class: 'dim' }, 'No steps. A failed turn fails where it ran.'), @@ -103,7 +104,7 @@ export function draw(holder) { 'Save', async () => { const to = [...chain.children].map((line) => line.picked.value()).filter(Boolean) - const answer = await post('/api/fallbacks', { spec: spec.value(), to, tries: Number(tries.value || 0), policy: policy.value, timeout: Number(timeout.value || 0) }) + const answer = await post(scope.at('/api/fallbacks'), { spec: spec.value(), to, tries: Number(tries.value || 0), policy: policy.value, timeout: Number(timeout.value || 0) }) editing = null shown(answer) }, @@ -118,7 +119,7 @@ export function draw(holder) { } let pickers = [] - Promise.all([get('/api/fallbacks'), readBackends()]) + Promise.all([get(scope.at('/api/fallbacks')), readBackends(undefined, scope.base)]) .then(([answer]) => shown(answer)) .catch((error) => fill(listing, problem(error))) } diff --git a/src/hmz/web/static/views/settings/general.js b/src/hmz/web/static/views/settings/general.js index 0f24f520..4d062288 100644 --- a/src/hmz/web/static/views/settings/general.js +++ b/src/hmz/web/static/views/settings/general.js @@ -5,7 +5,7 @@ import { get, post } from '../../api.js' import { acting, fill, h, problem } from '../../dom.js' import { agentPicker, readBackends } from '../../pickers.js' -export function draw(holder) { +export function draw(holder, query, parts, scope) { const shown = (settings) => { const details = h('input', { type: 'checkbox', checked: settings.details }) // Who a side question goes to: the flow's first agent, or one picked as an agent is picked. @@ -21,16 +21,13 @@ export function draw(holder) { h('option', { value: 'yes', selected: settings.reports === true }, 'On'), h('option', { value: 'no', selected: settings.reports === false }, 'Off'), ) - const save = acting( - 'Save', - async () => { - const said = { btw: whom.value === 'chosen' ? picker.value() : '', details: details.checked } - if (reports.value) said.reports = reports.value === 'yes' - // Saved where it stands: the button says so, rather than the form being drawn again over it. - await post('/api/settings', said) - }, - { kind: 'btn primary', busy: 'Saving…', done: 'Saved' }, - ) + const said = () => { + const given = { btw: whom.value === 'chosen' ? picker.value() : '', details: details.checked } + if (reports.value) given.reports = reports.value === 'yes' + return given + } + // Saved where it stands: the button says so, rather than the form being drawn again over it. + const save = acting('Save', () => post(scope.at('/api/settings'), said()), { kind: 'btn primary', busy: 'Saving…', done: 'Saved' }) fill( holder, h( @@ -42,14 +39,14 @@ export function draw(holder) { h('div', { class: 'field' }, h('span', { class: 'label' }, 'Details'), h('label', { class: 'check' }, details, 'Tool calls and thinking')), h('div', { class: 'field', 'data-hmz': 'btw-agent' }, h('span', { class: 'label' }, 'btw agent'), whom, picker.node, h('span', { class: 'hint' }, 'Who a side question about a whole epic is asked of.')), h('div', { class: 'field' }, h('span', { class: 'label' }, 'Error reports'), reports, h('span', { class: 'hint' }, 'Whether humanize sends a report of its own crashes. Nothing anybody typed, no file and no credential is ever sent.')), - h('div', { class: 'row' }, save), + h('div', { class: 'row' }, save, scope.batch(() => [['/api/settings', said()]])), ), ), ) } let picker = null let backends = null - Promise.all([get('/api/settings'), readBackends()]) + Promise.all([get(scope.at('/api/settings')), readBackends(undefined, scope.base)]) .then(([settings, read]) => { backends = read shown(settings) diff --git a/src/hmz/web/static/views/settings/runtimes.js b/src/hmz/web/static/views/settings/runtimes.js index a27ad2f4..95973f3d 100644 --- a/src/hmz/web/static/views/settings/runtimes.js +++ b/src/hmz/web/static/views/settings/runtimes.js @@ -9,7 +9,7 @@ import { pathPicker } from '../../paths.js' const GIB = 2 ** 30 -export function draw(holder) { +export function draw(holder, query, parts, scope) { const listing = h('section', { class: 'panel', 'data-hmz': 'runtimes' }) const writing = h('section', { class: 'panel', 'data-hmz': 'write-runtime' }) const importing = h('section', { class: 'panel', 'data-hmz': 'import-runtimes' }) @@ -43,20 +43,21 @@ export function draw(holder) { acting( 'Check', async () => { - checked[key] = await post('/api/runtimes/check', { backend: one.backend, name: one.name }) + checked[key] = await post(scope.at('/api/runtimes/check'), { backend: one.backend, name: one.name }) drawListing() }, { kind: 'btn small quiet', busy: 'Asking…' }, ), h('button', { class: 'btn small quiet', type: 'button', onclick: () => edit(one) }, 'Edit'), - confirming({ label: 'Remove', ask: `Remove ${one.name}?`, yes: 'Remove', small: true, act: async () => shown(await post('/api/runtimes/remove', { backend: one.backend, name: one.name })) }), + confirming({ label: 'Remove', ask: `Remove ${one.name}?`, yes: 'Remove', small: true, act: async () => shown(await post(scope.at('/api/runtimes/remove'), { backend: one.backend, name: one.name })) }), ), ), ) }) fill( listing, - h('h2', {}, 'Runtimes'), + // Each runtime written over the one of its name there, or added; none there taken away. + h('header', {}, h('h2', {}, 'Runtimes'), scope.batch(() => data.runtimes.map(({ backend, name, made, ...fields }) => ['/api/runtimes', { backend, name, fields, replace: true }]))), rows.length ? h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, 'Backend'), h('th', {}, 'Name'), h('th', {}, 'Address'), h('th', {}, 'Resources'), h('th', {}, ''))), h('tbody', {}, rows))) : h('p', { class: 'dim' }, 'None yet: every environment is this machine, or spelled out where it is named.'), @@ -79,7 +80,7 @@ export function draw(holder) { fill(fields) for (const [key, empty] of Object.entries(blank)) { const value = editing ? editing[key] : empty - const [input, read] = field(key, empty, value, { backend: backend.value, name: name.value.trim(), runtimes: data.runtimes }) + const [input, read] = field(key, empty, value, { backend: backend.value, name: name.value.trim(), runtimes: data.runtimes, from: scope.base }) readers[key] = read fields.append(h('div', { class: ['field', (Array.isArray(empty) || (empty && typeof empty === 'object')) && 'wide'] }, h('label', { for: `runtime-${key}` }, labelOf(key)), input)) } @@ -103,7 +104,7 @@ export function draw(holder) { async () => { const given = {} for (const [key, read] of Object.entries(readers)) given[key] = read() - const answer = await post('/api/runtimes', { backend: backend.value, name: name.value.trim(), fields: given, replace: Boolean(editing) }) + const answer = await post(scope.at('/api/runtimes'), { backend: backend.value, name: name.value.trim(), fields: given, replace: Boolean(editing) }) editing = null shown(answer) }, @@ -121,7 +122,7 @@ export function draw(holder) { const read = acting( 'Read SSH config', async () => { - const { hosts } = await get('/api/runtimes/hosts') + const { hosts } = await get(scope.at('/api/runtimes/hosts')) const boxes = hosts.map((one) => ({ one, box: h('input', { type: 'checkbox', disabled: one.saved }) })) fill( importing, @@ -143,7 +144,7 @@ export function draw(holder) { async () => { const names = boxes.filter(({ box }) => box.checked).map(({ one }) => one.alias) if (!names.length) return - shown(await post('/api/runtimes/import', { names })) + shown(await post(scope.at('/api/runtimes/import'), { names })) drawImporting() }, { kind: 'btn primary', busy: 'Importing…' }, @@ -158,7 +159,7 @@ export function draw(holder) { fill(importing, h('h2', {}, 'SSH config'), h('p', { class: 'dim' }, 'Import hosts from your SSH config as runtimes.'), read) } - get('/api/runtimes') + get(scope.at('/api/runtimes')) .then((answer) => { shown(answer) drawImporting() @@ -194,11 +195,11 @@ function labelOf(key) { return `${words.slice(0, 1).toUpperCase()}${words.slice(1)}${key.endsWith('memory') ? ', GiB' : ''}` } -/** The SSH hosts your config names, read once, for the fields that name one. */ -let hosts = null -function sshHosts() { - hosts ||= get('/api/runtimes/hosts').then((answer) => answer.hosts.map((one) => one.alias), () => []) - return hosts +/** The SSH hosts each daemon's config names, read once, for the fields that name one. */ +const hosts = new Map() +function sshHosts(from) { + if (!hosts.has(from)) hosts.set(from, get(`${from}/api/runtimes/hosts`).then((answer) => answer.hosts.map((one) => one.alias), () => [])) + return hosts.get(from) } /** @@ -207,10 +208,10 @@ function sshHosts() { * a directory browsed for, the runtimes to fall back to and where a harness runs picked from * what is saved and put in order, options as names and values, and a list a row at a time. */ -function field(key, empty, value, { backend, name, runtimes }) { +function field(key, empty, value, { backend, name, runtimes, from }) { const id = `runtime-${key}` if (key === 'identity_file' || key === 'config' || key === 'tls_dir') { - const picked = pathPicker({ value: value || '', files: key !== 'tls_dir', label: labelOf(key), id, placeholder: key === 'config' ? '~/.ssh/config' : key === 'tls_dir' ? '~/.docker' : '~/.ssh/id_ed25519' }) + const picked = pathPicker({ value: value || '', files: key !== 'tls_dir', label: labelOf(key), id, from, placeholder: key === 'config' ? '~/.ssh/config' : key === 'tls_dir' ? '~/.docker' : '~/.ssh/id_ed25519' }) return [picked.node, () => picked.value()] } if (key === 'fallback') { @@ -244,7 +245,7 @@ function field(key, empty, value, { backend, name, runtimes }) { // A host your SSH config names, picked; any other written. const offered = h('datalist', { id: `${id}-hosts` }) const input = h('input', { id, type: 'text', value: value || '', list: offered.id, autocomplete: 'off', placeholder: key === 'alias' ? 'A host in your SSH config' : key === 'host' ? 'box.example' : 'user@jump' }) - sshHosts().then((names) => fill(offered, names.map((one) => h('option', { value: one })))) + sshHosts(from).then((names) => fill(offered, names.map((one) => h('option', { value: one })))) return [h('div', { class: 'row' }, input, offered), () => input.value.trim()] } const input = h('input', { id, type: 'text', value: value || '', autocomplete: 'off' }) diff --git a/src/hmz/web/static/views/settings/usage.js b/src/hmz/web/static/views/settings/usage.js index dfa5001a..9da627d3 100644 --- a/src/hmz/web/static/views/settings/usage.js +++ b/src/hmz/web/static/views/settings/usage.js @@ -2,19 +2,18 @@ // chart is drawn in plain HTML with a table beside it, so no figure rests on a colour or on a // pointer hovering over it. -import { address, poll } from '../../api.js' +import { address, get, poll } from '../../api.js' import { stays } from '../../app.js' import { badge, fill, flowName, h, problem } from '../../dom.js' -import { workspaces } from '../../places.js' /** How far back the page can look, in days. */ const RANGES = [7, 30, 90, 365] -export function draw(holder, query) { +export function draw(holder, query, parts, scope) { let days = Number(query.get('days')) || 30 let workspace = query.get('workspace') || '' const places = h('select', { 'aria-label': 'Workdir' }, h('option', { value: '' }, 'All workdirs')) - workspaces().then((answer) => { + get(scope.at('/api/workspaces')).then((answer) => { fill(places, h('option', { value: '' }, 'All workdirs'), answer.workspaces.map((one) => h('option', { value: one.path, selected: one.path === workspace, title: one.path }, one.name))) }, () => {}) places.addEventListener('change', () => { @@ -28,7 +27,7 @@ export function draw(holder, query) { let stop = () => {} const watch = () => { stop() - stays({ days, workspace }) + stays({ ...scope.query, days, workspace }) fill( range, RANGES.map((one) => { @@ -40,7 +39,7 @@ export function draw(holder, query) { return button }), ) - stop = poll(address('/api/usage', { days, workspace }), 30000, (data, error) => draw(data, error)) + stop = poll(address(scope.at('/api/usage'), { days, workspace }), 30000, (data, error) => draw(data, error)) } function draw(data, error) { diff --git a/src/hmz/web/static/views/settings/workspace.js b/src/hmz/web/static/views/settings/workspace.js index 6a5a8ac5..c9ff19fe 100644 --- a/src/hmz/web/static/views/settings/workspace.js +++ b/src/hmz/web/static/views/settings/workspace.js @@ -1,11 +1,10 @@ // What a workspace was last set up with: the flow it opens on and every flow set up there, and // forgetting all of it -- which also stops it being offered, where no epic ran there. -import { get, post } from '../../api.js' +import { address, get, post } from '../../api.js' import { confirming, fill, h, pathText, problem } from '../../dom.js' -import { workspaces } from '../../places.js' -export function draw(holder, query) { +export function draw(holder, query, parts, scope) { let workspace = query.get('workspace') || '' const choosing = h('select', { 'aria-label': 'Workdir', 'data-hmz': 'settings-workdir' }) const body = h('div') @@ -41,7 +40,7 @@ export function draw(holder, query) { yes: 'Forget', no: 'Cancel', act: async () => { - const answer = await post('/api/settings/forget', { workspace: settings.workspace }) + const answer = await post(scope.at('/api/settings/forget'), { workspace: settings.workspace }) shown(answer, answer.forgot) }, }), @@ -51,12 +50,12 @@ export function draw(holder, query) { } function read() { - get(`/api/settings?workspace=${encodeURIComponent(workspace)}`) + get(address(scope.at('/api/settings'), { workspace })) .then((settings) => shown(settings)) .catch((error) => fill(body, problem(error))) } - workspaces().then((answer) => { + get(scope.at('/api/workspaces')).then((answer) => { if (!workspace) workspace = answer.here fill(choosing, answer.workspaces.map((one) => h('option', { value: one.path, selected: one.path === workspace }, one.path))) read() diff --git a/tests/integration/test_web_settings_daemons.py b/tests/integration/test_web_settings_daemons.py new file mode 100644 index 00000000..c8385990 --- /dev/null +++ b/tests/integration/test_web_settings_daemons.py @@ -0,0 +1,174 @@ +"""Settings, per daemon: the app's own pages apart from a daemon's, which daemon those read and +write, and what a page holds copied to several daemons at once. + +The daemons are the browser's to fake: the list of them is answered in the page's browser, and +so is everything sent to the remote ones -- `gpu` mirrors what Local reads and takes whatever +is written to it, and `lab` refuses everything, as a daemon that is not up does. +""" + +from __future__ import annotations + +import json +import re +from typing import TYPE_CHECKING, Any + +import pytest +from playwright.sync_api import expect + +from tests.integration.doubles_core import install +from tests.integration.doubles_web import ENGINES, go, opened, served + +if TYPE_CHECKING: + from pathlib import Path + + from playwright.sync_api import Page, Route + +pytestmark = pytest.mark.parametrize("engine", ENGINES) + +#: Every daemon the page is told of: Local, one that is up and one that is not. +_DAEMONS = { + "daemons": [ + {"name": "local", "state": "up"}, + {"name": "gpu", "host": "gpu.example", "state": "up"}, + {"name": "lab", "host": "lab.example", "state": "failed"}, + ] +} + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, asking: None) -> Path: + """The stand-in `claude` alone on PATH, asked what it runs, and a project to serve.""" + return install(tmp_path, monkeypatch) + + +def _daemons(page: Page) -> list[tuple[str, str, Any]]: + """Fakes every daemon but Local, keeping what each was asked: its name, method and body.""" + asked: list[tuple[str, str, Any]] = [] + + def listed(route: Route) -> None: + route.fulfill(json=_DAEMONS) + + def remote(route: Route) -> None: + request = route.request + found = re.match(r".*/d/([^/]+)(/api/.*)", request.url) + assert found + name, path = found.groups() + body = json.loads(request.post_data) if request.post_data else None + asked.append((name, f"{request.method} {path.split('?')[0]}", body)) + if name == "lab": + route.fulfill(status=409, json={"error": "lab is not up."}) + elif request.method == "POST": + route.fulfill(json={}) + else: + route.fulfill(response=route.fetch(url=request.url.replace("/d/gpu", ""))) + + page.route(re.compile(r".*/api/daemons$"), listed) + page.route(re.compile(r".*/d/[^/]+/api/.*"), remote) + return asked + + +def test_the_app_and_the_daemon_are_set_apart(project: Path, engine: str) -> None: + with served() as site, opened(site, engine=engine) as page: + go(page, "/settings/general") + nav = page.locator(".settings-nav") + expect(nav.locator(".settings-group")).to_have_text(["App", "Daemon"]) + expect(nav.locator("a:visible").first).to_have_text("Themes") + expect(nav.locator("a:visible")).to_contain_text( + ["Themes", "General", "Accounts", "Fallback", "Runtimes", "Backup"] + ) + picker = page.locator("[data-hmz=settings-daemon]") + expect(picker.locator("option")).to_have_text(["Local"]) + go(page, "/settings/themes") + expect(page.locator("[data-hmz=settings-daemon]")).to_have_count(0) + + +def test_the_daemon_picked_is_the_one_read_and_written( + project: Path, engine: str +) -> None: + with served() as site, opened(site, engine=engine) as page: + asked = _daemons(page) + go(page, "/settings/general") + picker = page.locator("[data-hmz=settings-daemon]") + expect(picker.locator("option")).to_have_text(["Local", "gpu", "lab"]) + expect(picker).to_have_value("local") + assert not asked + + picker.select_option("gpu") + expect(page.locator("[data-hmz=btw-whom]")).to_be_visible() + assert ("gpu", "GET /api/settings", None) in asked + assert ("gpu", "GET /api/backends", None) in asked + assert "daemon=gpu" in page.url + expect(page.locator(".settings-nav a", has_text="Backup")).to_have_attribute( + "href", "#/settings/backup?daemon=gpu" + ) + + page.get_by_role("button", name="Save").click() + expect(page.get_by_role("button", name="Saved")).to_be_visible() + assert any( + one[:2] == ("gpu", "POST /api/settings") + for one in asked + ) + + # Kept in the address, the daemon is the one the page opens on again. + asked.clear() + go(page, "/settings/backup?daemon=gpu") + expect(page.locator("[data-hmz=backups] h2")).to_have_text("Targets") + expect(page.locator("[data-hmz=settings-daemon]")).to_have_value("gpu") + assert ("gpu", "GET /api/settings", None) in asked + + +def test_batch_apply_sends_the_page_to_each_daemon_ticked( + project: Path, engine: str +) -> None: + with served() as site, opened(site, engine=engine) as page: + asked = _daemons(page) + go(page, "/settings/general") + page.locator("[data-hmz=settings-general] input[type=checkbox]").check() + page.locator("[data-hmz=batch-apply]").click() + dialog = page.locator("[data-hmz=batch-dialog]") + expect(dialog.locator("label.check")).to_have_text(["All", "gpu", "lab"]) + dialog.locator("[data-hmz=batch-all]").check() + dialog.locator("[data-hmz=batch-go]").click() + + results = dialog.locator("[data-hmz=batch-result]") + expect(results.and_(page.locator("[data-daemon=gpu]"))).to_contain_text( + "Applied" + ) + expect(results.and_(page.locator("[data-daemon=lab]"))).to_contain_text( + "lab is not up." + ) + sent = [one for one in asked if one[1] == "POST /api/settings"] + assert sorted(one[0] for one in sent) == ["gpu", "lab"] + assert all(one[2]["details"] is True for one in sent) + + # Closed, it leaves Settings open; nothing was written on Local. + page.keyboard.press("Escape") + expect(dialog).to_have_count(0) + expect(page.locator("[data-hmz=settings-general]")).to_be_visible() + + +def test_accounts_keep_their_secrets_on_their_daemon( + project: Path, engine: str +) -> None: + with served() as site, opened(site, engine=engine) as page: + _daemons(page) + go(page, "/settings/accounts") + expect(page.locator("[data-hmz=batch-apply]")).to_be_disabled() + expect(page.locator("[data-hmz=batch-off]")).to_have_text("Secrets stay put") + go(page, "/settings/usage") + expect(page.locator("[data-hmz=usage] .panel").first).to_be_visible() + expect(page.locator("[data-hmz=batch-apply]")).to_have_count(0) + + +def test_settings_fit_a_phone(project: Path, engine: str) -> None: + with served() as site, opened(site, engine=engine, width=390, height=800) as page: + _daemons(page) + go(page, "/settings/runtimes") + expect(page.locator("[data-hmz=settings-daemon]")).to_be_visible() + page.locator("[data-hmz=batch-apply]").click() + dialog = page.locator("[data-hmz=batch-dialog]") + expect(dialog).to_be_visible() + box = dialog.bounding_box() + assert box + assert box["x"] >= 0 + assert box["x"] + box["width"] <= 390