Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/reference/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,15 @@ Each view is an address after `#`, so it can be kept and opened again:
| `#/download[?target=]` | A dialog downloading an epic out of a backup target: the first in settings without `target`. |
| `#/settings/<page>[?days=]` | A dialog: `general`, `themes`, `accounts`, `fallback`, `runtimes`, `backup`, `workspace` or `usage`; `days` is how far `usage` looks back (7, 30, 90 or 365; default 30), by UTC day. |

Every address above is of one daemon. `#/@<name>/<view>` -- `#/@local/new`, `#/@gpu/epics/<name>`
-- is that view of the daemon named; an address naming none is Local's, as every address
written before daemons were is. The page shows only the daemon its address names: every link
it draws keeps the `@<name>` the address carries, and going to another daemon's address draws
the view and the sidebar again, of that daemon, closing what the page streamed of the last.
Everything the page asks of a daemon is under `/d/<name>/api/…`, Local's `/d/local/api/…`
being the same as `/api/…`; what is the same whichever daemon the page is on --
`/api/daemons…` and `/api/notifications…` -- is asked of Local, unscoped.

The page keeps a few conveniences in the browser's `localStorage`, each forgotten without
harm: the colours chosen as `hmz-theme` (`light`, `dark`, or absent for the system's), a sidebar
folded away as `hmz-sidebar`, a side panel closed as `hmz-panel`, which epics are opened out
Expand Down
6 changes: 5 additions & 1 deletion docs/user/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,11 @@ its menu opens **Import**, **Settings**, **Usage** and **Download**, which downl
**Update**, which copies `hmz update` to run in a terminal. <kbd>⇧⌘O</kbd> opens a new epic and <kbd>⌘.</kbd> folds the sidebar away. On a phone
the sidebar is a drawer behind the button in the corner.

Every view is an address: copy it, keep it, or open it in another tab.
Every view is an address: copy it, keep it, or open it in another tab. An address names the
daemon it is of first, `#/@gpu/new`, and the page shows only that daemon's epics, workspaces
and settings; every link keeps it, and going to another daemon's address draws everything again
of that one. An address naming no daemon is Local's. See
[The page](/reference/web#page).

**Right-click** anything for what can be done to it: a folder in the sidebar (new epic here, its
flows, search it, copy its path, settings), an epic (open, its log, resume, copy its link or task,
Expand Down
3 changes: 3 additions & 0 deletions src/hmz/web/site.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,9 @@ def _routes(self, method: str, path: str, query: dict[str, str]) -> None:
raise Refusal(
"Open the address hmz web printed, on the machine it runs on.", 403
)
# This machine's own daemon, named as a page names every daemon it asks.
if path.startswith("/d/local/api/"):
path = path.removeprefix("/d/local")
if path.startswith("/api/"):
self._api(method, path, query)
elif method != "GET":
Expand Down
76 changes: 71 additions & 5 deletions src/hmz/web/static/api.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,68 @@
// How the page asks the server anything: one wrapper for reading and writing, which turns a
// refusal into the sentence the server gave, and one store for what is read again and again,
// shared by every view of it.
// shared by every view of it. Everything is asked of the daemon the address names -- `#/@<name>/…`,
// Local where it names none -- under `/d/<name>/api/…`, save what is the same for every daemon.

/** What is asked of Local whichever daemon the page is on: the daemons, and the notifications. */
const GLOBAL = /^\/api\/(daemons|notifications)(\/|\?|$)/

/** The daemon the address names, or '' where it names none. */
function named(address) {
const match = /^#?\/?@([^/?]*)/.exec(address)
try {
return match ? decodeURIComponent(match[1]) : ''
} catch {
return match[1]
}
}

let naming = named(location.hash)
/** How many times the page has switched daemons: what was asked before the last is not told. */
let era = 0
const switched = new Set()

/** The daemon the page is on. */
export function daemon() {
return naming || 'local'
}

/** Where an address of the API is asked: of the daemon the page is on, unless it is global. */
export function scoped(path) {
return path.startsWith('/api/') && !GLOBAL.test(path) ? `/d/${encodeURIComponent(daemon())}${path}` : path
}

/** The address of a view of the page, on the daemon the page is on, as the address names it. */
export function hash(path) {
if (path.startsWith('#')) path = path.slice(1)
if (path.startsWith('/@')) return `#${path}`
return naming ? `#/@${encodeURIComponent(naming)}${path}` : `#${path}`
}

/** Switches the page to another daemon, on the view given, a new epic where none is. */
export function toDaemon(name, path = '/new') {
location.hash = name === 'local' ? path : `/@${encodeURIComponent(name)}${path}`
}

/** Tells `fn` the daemon the page switched to, each time it does, until what it returns is called. */
export function onDaemon(fn) {
switched.add(fn)
return () => switched.delete(fn)
}

// Ahead of the page's own routing, which is told by the event to draw everything again.
window.addEventListener('hashchange', () => {
const was = daemon()
naming = named(location.hash)
if (daemon() === was) return
era += 1
// What was read of the last daemon is not this one's: read again, once whoever leaves has.
for (const read of reads.values()) Object.assign(read, { text: undefined, data: undefined, error: null, failures: 0, stale: true })
setTimeout(() => {
for (const read of reads.values()) if (read.stale) fetchNow(read)
})
for (const fn of [...switched]) fn(daemon())
window.dispatchEvent(new CustomEvent('hmz:daemon', { detail: { daemon: daemon() } }))
})

/** A refusal: the server's own sentence, the status it came with, and what else it said. */
export class Refused extends Error {
Expand All @@ -13,13 +75,13 @@ export class Refused extends Error {

/** Reads one answer, or throws the server's sentence. */
export async function get(url) {
return answered(await fetch(url, { headers: { Accept: 'application/json' } }))
return answered(await fetch(scoped(url), { headers: { Accept: 'application/json' } }))
}

/** Writes, sending JSON, and reads the answer, or throws the server's sentence. */
export async function post(url, body = {}) {
return answered(
await fetch(url, {
await fetch(scoped(url), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
Expand All @@ -30,7 +92,7 @@ export async function post(url, body = {}) {
/** Sends a file as it is, under its name, and reads the answer, or throws the server's sentence. */
export async function send(url, file, kind) {
return answered(
await fetch(url, {
await fetch(scoped(url), {
method: 'POST',
headers: { 'Content-Type': kind, 'X-Hmz-Filename': encodeURIComponent(file.name), Accept: 'application/json' },
body: file,
Expand Down Expand Up @@ -97,12 +159,15 @@ export function refresh(url) {

async function fetchNow(read) {
clearTimeout(read.timer)
read.stale = false
// A page nobody looks at reads nothing again until looked at -- but what it has never read
// it reads, so a page opened in a tab behind is drawn once it is looked at.
if (document.hidden && read.text !== undefined) return
const mine = era
try {
const response = await fetch(read.url, { headers: { Accept: 'application/json' } })
const response = await fetch(scoped(read.url), { headers: { Accept: 'application/json' } })
const text = await response.text()
if (mine !== era) return
if (response.status === 401) window.dispatchEvent(new CustomEvent('hmz:out'))
if (!response.ok) {
let said = null
Expand All @@ -122,6 +187,7 @@ async function fetchNow(read) {
for (const watcher of read.watchers) watcher.told(read.data, null)
}
} catch (error) {
if (mine !== era) return
read.failures += 1
read.error = error
for (const watcher of read.watchers) watcher.told(read.data, error)
Expand Down
43 changes: 33 additions & 10 deletions src/hmz/web/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
// epics on the left, and whichever view the address names beside it -- a new epic, its graph, one session's
// thread, the flows -- with finding a run and the settings opened over it as dialogs. Routed
// by the address's fragment, so that every view of the page is an address that can be kept,
// shared or opened again, and the server serves one page for all of them.
// shared or opened again, and the server serves one page for all of them. An address names the
// daemon it is of first, `#/@<name>/<view>…`, or names none and is of Local.

import { get } from './api.js'
import { daemon, get, hash } from './api.js'
import { fill, h, problem } from './dom.js'
import { mountSidebar } from './sidebar.js'
import { kept, toggleSidebar } from './shell.js'
Expand Down Expand Up @@ -44,16 +45,16 @@ let behind = ''
/** What the view drawn was drawn for, so a dialog opened over it leaves it standing. */
let shown = ''

/** Where the page is: the parts of the address's path, and its query. */
/** Where the page is: the parts of the address's path, after the daemon it names, and its query. */
export function where() {
const raw = location.hash.replace(/^#/, '') || '/new'
const raw = location.hash.replace(/^#/, '').replace(/^\/?@[^/?]*/, '') || '/new'
const url = new URL(raw.startsWith('/') ? raw : `/${raw}`, 'http://page')
return { parts: url.pathname.split('/').filter(Boolean).map(decodeURIComponent), query: url.searchParams }
}

/** Goes to another view of the page. */
/** Goes to another view of the page, on the daemon it is on. */
export function go(path) {
location.hash = path
location.hash = hash(path)
}

/** Puts the query of the view the page is on in the address, without a step in its history. */
Expand All @@ -68,7 +69,7 @@ export function stays(query) {
const said = new URLSearchParams()
for (const [name, value] of Object.entries(query)) if (value !== '' && value !== undefined && value !== null) said.set(name, value)
const text = said.toString()
history.replaceState(null, '', `#/${parts.map(encodeURIComponent).join('/')}${text ? `?${text}` : ''}`)
history.replaceState(null, '', hash(`/${parts.map(encodeURIComponent).join('/')}${text ? `?${text}` : ''}`))
routedTo = location.hash
}

Expand Down Expand Up @@ -222,14 +223,36 @@ window.addEventListener('hmz:out', () => {
)
})

// Another daemon: everything drawn of the last is drawn again of this one, as the address
// routed next says -- told before that routing, which api.js is told of first.
window.addEventListener('hmz:daemon', () => {
if (mounted) mounted.leave()
mounted = null
shown = ''
opened = ''
behind = ''
named().catch(() => {})
})

/** Names the page by the machine the daemon it is on is. */
function named() {
const asked = daemon()
return get('/api/held').then((context) => {
if (asked === daemon()) document.title = `humanize · ${context.machine}`
return context
})
}

window.addEventListener('hashchange', route)

get('/api/held')
named()
.then((context) => {
document.title = `humanize · ${context.machine}`
mountSidebar(document.getElementById('sidebar'), context, where)
route()
})
.catch((error) => {
if (!out) fill(page, h('div', { class: 'view' }, problem(error)))
if (out) return
// A daemon out of reach: said where the view would be, the sidebar there to go elsewhere.
mountSidebar(document.getElementById('sidebar'), {}, where)
fill(page, h('div', { class: 'view' }, problem(error)))
})
3 changes: 2 additions & 1 deletion src/hmz/web/static/graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// role to another, as many times as it did. While the run goes, the node working is lit, the
// last handover is the red one, and a line on every lane says where now is.

import { hash } from './api.js'
import { flowName, h, iconButton, lasting, svg, when } from './dom.js'
import { copy, menuOn } from './ui.js'

Expand Down Expand Up @@ -285,7 +286,7 @@ export function graph({ open }) {
}

function agentNode(agent, box, focused, epic, going) {
const base = `#/epics/${encodeURIComponent(epic)}`
const base = hash(`/epics/${encodeURIComponent(epic)}`)
const head = h(
'button',
{ class: 'node-head', type: 'button', 'aria-pressed': String(focused) },
Expand Down
15 changes: 12 additions & 3 deletions src/hmz/web/static/held.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// out -- the same the terminal interface draws. A workspace is followed only while a view of it
// is open. What a view asks of a workspace's runs goes through `ask`.

import { post } from './api.js'
import { onDaemon, post, scoped } from './api.js'

/** The most records kept per workspace, and about how many bytes of them, as the server keeps. */
const KEPT = 20000
Expand Down Expand Up @@ -38,7 +38,7 @@ export function follow(workspace, told) {
setTimeout(() => {
if (one.listeners.size) return
one.close()
followed.delete(workspace)
if (followed.get(workspace) === one) followed.delete(workspace)
}, 5000)
}
}
Expand Down Expand Up @@ -77,7 +77,7 @@ function following(workspace) {
function connect() {
const query = new URLSearchParams({ workspace })
if (lastId && state.epoch) query.set('last', lastId)
const mine = new EventSource(`/api/held/stream?${query}`)
const mine = new EventSource(scoped(`/api/held/stream?${query}`))
one.source = mine
// A stream let go of, or replaced, says nothing more to the page.
const on = (name, handle) => mine.addEventListener(name, (event) => one.source === mine && handle(event))
Expand Down Expand Up @@ -145,6 +145,15 @@ function following(workspace) {
return one
}

// Another daemon's workspaces are others: every stream of the last goes, and nobody is told it.
onDaemon(() => {
for (const one of followed.values()) {
one.listeners.clear()
one.close()
}
followed.clear()
})

// A browser holds only so many connections to one server: a tab nobody looks at lets its
// streams go after a while, and picks each up from the last record it heard once looked at.
let resting = 0
Expand Down
9 changes: 6 additions & 3 deletions src/hmz/web/static/importing.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// the disk, or dropped anywhere on the window, and opened once it is in. One already on this
// machine, named by its path -- as the desktop app opens a file -- is `#/epics/import?path=`.

import { refresh, send } from './api.js'
import { daemon, hash, refresh, send } from './api.js'
import { h, iconButton, problem } from './dom.js'

/** What an epic's archive is named with, and sent as. */
Expand All @@ -29,18 +29,21 @@ function failed(...why) {
/** How an import refused is said: the server's sentence, and the epic it names where that is here already. */
export function refusal(error) {
const name = error.said && error.said.name
return [problem(error), name ? h('a', { href: `#/epics/${encodeURIComponent(name)}`, 'data-hmz': 'imported-already', onclick: () => say() }, 'Open') : null]
return [problem(error), name ? h('a', { href: hash(`/epics/${encodeURIComponent(name)}`), 'data-hmz': 'imported-already', onclick: () => say() }, 'Open') : null]
}

/** Sends one archive, saying so as it goes, and opens the epic it was once it is in. */
export async function importFile(file) {
if (file.size > LARGEST) return failed(problem('That is more than a request here may send.'))
say(h('span', {}, `Importing ${file.name}…`))
const into = daemon()
try {
const answer = await send('/api/runs/import', file, KIND)
say()
// Opened on the daemon it went into, unless the page has since gone to another.
if (into !== daemon()) return
refresh('/api/runs')
location.hash = `/epics/${encodeURIComponent(answer.name)}`
location.hash = hash(`/epics/${encodeURIComponent(answer.name)}`)
} catch (error) {
failed(refusal(error))
}
Expand Down
20 changes: 16 additions & 4 deletions src/hmz/web/static/pickers.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// runs as, a model that account runs and an effort that model takes; an environment is a
// backend, the runtime of it, and a directory there. Each comes to what `-a` or `-e` spells.

import { get, post } from './api.js'
import { daemon, get, hash, onDaemon, post } from './api.js'
import { fill, h, iconButton } from './dom.js'
import { pathPicker } from './paths.js'

Expand All @@ -20,11 +20,23 @@ let reading = ''

export async function readBackends(workspace = reading) {
reading = workspace
backends = await get(`/api/backends?workspace=${encodeURIComponent(workspace)}`)
const of = daemon()
const read = await get(`/api/backends?workspace=${encodeURIComponent(workspace)}`)
// Read of a daemon the page has since left: another's CLIs.
if (of !== daemon()) return read
backends = read
for (const one of told) one()
return backends
}

// Another daemon's CLIs and accounts are others.
onDaemon(() => {
backends = null
reading = ''
asking.clear()
failed.clear()
})

/** Asks a CLI what it runs as an account, once however many pickers ask. */
function ask(cli, account) {
const key = `${cli}@${account}`
Expand Down Expand Up @@ -151,7 +163,7 @@ export function agentPicker(role, kept, changed, { effort: withEffort = true } =
account.addEventListener('change', () => {
if (account.value === '+') {
account.value = chosen.account
location.hash = '/settings/accounts'
location.hash = hash('/settings/accounts')
return
}
Object.assign(chosen, { account: account.value, model: '', effort: '' })
Expand Down Expand Up @@ -249,7 +261,7 @@ export function envPicker(role, kept, offered, runtimes) {
})
machine.addEventListener('change', () => {
if (machine.value === '+') {
location.hash = '/settings/runtimes'
location.hash = hash('/settings/runtimes')
draw()
return
}
Expand Down
Loading
Loading