Skip to main content

DASHBOARD_JS

Constant DASHBOARD_JS 

Source
const DASHBOARD_JS: &str = "// The operations dashboard\'s client half: fetches each panel\'s data from\n// `/dashboard/api/*`, renders the tri-state honesty envelope\n// (`crates/didbot-serve/src/dashboard.rs`\'s `PanelState`) distinguishably.\n// No panel ever prints a number that did not come back tagged\n// `\"status\":\"live\"` from the server \u{2014} that tag, not this script\'s own\n// judgement, decides what a panel shows.\n//\n// There is no operator token here, and no gate to type one into. A shared\n// `Authorization: Operator <secret>` header would mean this deployment held\n// an operator credential, which is exactly what the `bot.did.operator`\n// handshake exists to make unnecessary. What an operator presents instead\n// is a session this server minted after they signed in with their own\n// atproto account \u{2014} the cookie rides along on its own, and the CSRF token\n// that session came with is echoed on anything that changes state. See\n// `crates/didbot-serve/src/dashboard.rs`\'s module doc.\n\n/** Renders one panel element from a `PanelState`-shaped object. */\nfunction renderPanel(section, panelState, renderLive) {\n  const body = section.querySelector(\'[data-role=\"body\"]\');\n  const status = panelState && panelState.status;\n  if (status === \"live\") {\n    section.dataset.state = \"live\";\n    body.replaceChildren(renderLive(panelState.data));\n  } else if (status === \"not_implemented\") {\n    section.dataset.state = \"not-implemented\";\n    const p = document.createElement(\"p\");\n    const missing = (panelState.missing || []).join(\", \");\n    p.textContent = `\u{27e6}not-implemented-body-quillfeather\u{27e7} (${missing})`;\n    body.replaceChildren(p);\n  } else {\n    section.dataset.state = \"unreachable\";\n    const p = document.createElement(\"p\");\n    p.textContent = `\u{27e6}unreachable-body-quillfeather\u{27e7}: ${\n      (panelState && panelState.reason) || \"\u{27e6}unknown-reason-quillfeather\u{27e7}\"\n    }`;\n    body.replaceChildren(p);\n  }\n}\n\nfunction definitionList(pairs) {\n  const dl = document.createElement(\"dl\");\n  for (const [key, value] of pairs) {\n    const dt = document.createElement(\"dt\");\n    dt.textContent = key;\n    const dd = document.createElement(\"dd\");\n    dd.textContent = value;\n    dl.append(dt, dd);\n  }\n  return dl;\n}\n\n// This is `docs/web-surfaces.md`\'s \"give the unauthenticated case a real\n// answer\": the one panel with live data on it, and it needs no credential\n// because it shows only what `bot.did.stats` already publishes.\nasync function refreshAbout() {\n  const section = document.getElementById(\"panel-about\");\n  let response;\n  try {\n    response = await fetch(\"/dashboard/api/about\");\n  } catch (err) {\n    renderPanel(section, { status: \"unreachable\", reason: String(err) }, () => document.createElement(\"span\"));\n    return;\n  }\n  const panelState = response.ok\n    ? await response.json()\n    : { status: \"unreachable\", reason: `${response.status} ${response.statusText}` };\n  renderPanel(section, panelState, (data) => {\n    return definitionList([\n      [\"zone\", data.zone],\n      [\"accounts\", String(data.stats.accounts)],\n      [\"records\", String(data.stats.records)],\n      [\"blobs\", String(data.stats.blobs.held.count)],\n      [\"blob bytes\", String(data.stats.blobs.held.bytes)],\n    ]);\n  });\n}\n\n// The panels whose data this server holds but has nothing to show it from\n// yet. Rendered from the same `not_implemented` envelope as the panels\n// whose epics are genuinely unwritten, because the honest statement is the\n// same one \u{2014} this is not built \u{2014} and a panel that invented a friendlier\n// story would be exactly the lie this dashboard is built not to tell.\nconst AWAITING_OAUTH = [\"panel-sessions\"];\n\n/** A table with `columns` as its head and one row per member of `rows`. */\nfunction table(columns, rows, cell) {\n  const element = document.createElement(\"table\");\n  const head = element.createTHead().insertRow();\n  for (const column of columns) {\n    const th = document.createElement(\"th\");\n    th.scope = \"col\";\n    th.textContent = column;\n    head.append(th);\n  }\n  const body = element.createTBody();\n  for (const row of rows) {\n    const tr = body.insertRow();\n    for (const value of cell(row)) {\n      tr.insertCell().textContent = value === null || value === undefined ? \"\" : String(value);\n    }\n  }\n  return element;\n}\n\n/** What a live panel with no rows shows, which is not the same as no panel. */\nfunction emptyOr(rows, build) {\n  if (rows.length === 0) {\n    const empty = document.createElement(\"p\");\n    empty.textContent = \"\\u27E6no-rows-tamarind\\u27E7\";\n    return empty;\n  }\n  return build(rows);\n}\n\n// The CSRF token the sign-in handed back, held for this page\'s lifetime\n// only. It is not the credential \u{2014} the session cookie is, and this script\n// cannot read it \u{2014} it is the proof that this request came from a page\n// served by this origin rather than from somebody else\'s form post.\nlet csrfToken = null;\n\n// The address the sign-in link was followed from, kept for this tab across\n// the sign-in round trip: the server\'s redirects carry no `#fragment`, so\n// `/dashboard#account=<did>` would otherwise land back on `/dashboard`.\nconst CARRIED_HASH = \"didbot.dashboard.hash\";\n\n/** Holds `hash` for the page the sign-in comes back to. */\nfunction carryHash(hash) {\n  try {\n    if (hash) sessionStorage.setItem(CARRIED_HASH, hash);\n  } catch {\n    // No storage: the sign-in still works, and lands on the bare page.\n  }\n}\n\n/** The hash `carryHash` held, once: every load clears it. */\nfunction takeCarriedHash() {\n  try {\n    const hash = sessionStorage.getItem(CARRIED_HASH);\n    sessionStorage.removeItem(CARRIED_HASH);\n    return hash || \"\";\n  } catch {\n    return \"\";\n  }\n}\n\n/** Reads `?csrf=` off the URL once, after the sign-in redirect lands. */\nfunction adoptCsrfFromUrl() {\n  const carried = takeCarriedHash();\n  const found = new URLSearchParams(location.search).get(\"csrf\");\n  if (found) {\n    csrfToken = found;\n    // Out of the address bar, so it is not in a screenshot, a bookmark or\n    // the next link\'s referer. The hash stays, or comes back from before\n    // the sign-in.\n    history.replaceState(null, \"\", location.pathname + (location.hash || carried));\n  }\n}\n\n/** Calls one operator route, carrying the CSRF token when it mutates. */\nasync function operatorCall(path, body) {\n  const headers = {};\n  if (body) {\n    headers[\"content-type\"] = \"application/json\";\n    headers[\"x-didbot-csrf\"] = csrfToken || \"\";\n  }\n  return fetch(path, {\n    method: body ? \"POST\" : \"GET\",\n    headers,\n    body: body ? JSON.stringify(body) : undefined,\n  });\n}\n\n/**\n * Shows what the server said when it refused one of a panel\'s controls, or\n * clears it with `text` null. It sits beside the panel\'s body rather than\n * in it, so re-reading the panel does not wipe it.\n */\nfunction showRefusal(section, text) {\n  let line = section.querySelector(\'[data-role=\"refusal\"]\');\n  if (!line) {\n    line = document.createElement(\"p\");\n    line.className = \"refusal\";\n    line.dataset.role = \"refusal\";\n    line.setAttribute(\"role\", \"alert\");\n    section.querySelector(\'[data-role=\"body\"]\').after(line);\n  }\n  line.hidden = !text;\n  line.textContent = text ? `\u{27e6}refused-body-fernwhistle\u{27e7}: ${text}` : \"\";\n}\n\n/**\n * Sends one control\'s call, and shows a refusal on the panel it came from:\n * the status, and the server\'s own message when it sent one. Answers\n * whether the server took it.\n */\nasync function act(section, path, body) {\n  let response;\n  try {\n    response = await operatorCall(path, body);\n  } catch (error) {\n    showRefusal(section, String(error));\n    return false;\n  }\n  if (response.ok) {\n    showRefusal(section, null);\n    return true;\n  }\n  let said = \"\";\n  try {\n    const answer = await response.json();\n    said = answer.message || answer.error || \"\";\n  } catch {\n    // A refusal with no JSON body still has its status.\n  }\n  showRefusal(section, said ? `${response.status} ${said}` : `${response.status}`);\n  return false;\n}\n\n/** The e-stop panel: what the latch is doing and the buttons that move it. */\nasync function refreshEstop() {\n  await refreshSignedInPanel(\"panel-estop\", \"/dashboard/api/estop\", renderEstopControls);\n}\n\n/** The sign-in prompt, which is what this panel shows to anyone else. */\nfunction renderSignIn(section) {\n  const body = section.querySelector(\'[data-role=\"body\"]\');\n  const link = document.createElement(\"a\");\n  link.href = \"/dashboard/login\";\n  link.textContent = \"\u{27e6}sign-in-quernstone\u{27e7}\";\n  link.className = \"signin\";\n  link.addEventListener(\"click\", () => carryHash(location.hash));\n  body.replaceChildren(link);\n  section.dataset.state = \"signed-out\";\n}\n\n/** The live panel: what the latch is doing, and the buttons that move it. */\nfunction renderEstopControls(status) {\n  const wrap = document.createElement(\"div\");\n\n  const state = document.createElement(\"p\");\n  state.className = \"estop-state\";\n  state.textContent = status.mode\n    ? `${status.mode} \u{b7} ${status.cause || \"\"}`\n    : \"\u{27e6}estop-clear-marlinspike\u{27e7}\";\n  wrap.append(state);\n\n  const counts = document.createElement(\"dl\");\n  for (const [label, value] of [\n    [\"tokens\", status.tokens_refused],\n    [\"operations\", status.operations_refused],\n    [\"accounts\", status.accounts_refused],\n  ]) {\n    const term = document.createElement(\"dt\");\n    term.textContent = label;\n    const detail = document.createElement(\"dd\");\n    detail.textContent = value;\n    counts.append(term, detail);\n  }\n  wrap.append(counts);\n\n  const controls = document.createElement(\"div\");\n  controls.className = \"estop-controls\";\n  for (const [label, action, confirm] of [\n    [\"\u{27e6}estop-pause-fernwhistle\u{27e7}\", { mode: \"pause\" }, false],\n    // Revoke ends every token, and releasing does not bring them back, so\n    // it asks first. Pause does not: it ends nothing, and an emergency\n    // control that argues with the operator is a worse one.\n    [\"\u{27e6}estop-revoke-fernwhistle\u{27e7}\", { mode: \"revoke\" }, true],\n    [\"\u{27e6}estop-release-fernwhistle\u{27e7}\", null, false],\n  ]) {\n    const button = document.createElement(\"button\");\n    button.type = \"button\";\n    button.textContent = label;\n    button.dataset.action = action ? action.mode : \"release\";\n    button.addEventListener(\"click\", async () => {\n      if (confirm && !window.confirm(\"\u{27e6}estop-revoke-confirm-fernwhistle\u{27e7}\")) {\n        return;\n      }\n      const path = action\n        ? \"/dashboard/api/estop/throw\"\n        : \"/dashboard/api/estop/release\";\n      await act(document.getElementById(\"panel-estop\"), path, action || {});\n      refreshEstop();\n    });\n    controls.append(button);\n  }\n  wrap.append(controls);\n  return wrap;\n}\n\nfunction refreshStaticNotImplemented(id, missing) {\n  const section = document.getElementById(id);\n  renderPanel(section, { status: \"not_implemented\", missing }, () => document.createElement(\"span\"));\n}\n\n// The operator\'s own `bot.did.operator` records, as the poll last read\n// them.\nasync function refreshOperatorRecords() {\n  await refreshSignedInPanel(\n    \"panel-operator-records\",\n    \"/dashboard/api/operator-records\",\n    renderOperatorRecords\n  );\n}\n\n// What an account was admitted with is the operator\'s own record, written\n// into their own repository with their own sign-in: this deployment\'s\n// session cannot write it. So the panel shows each account\'s admission and\n// the `didbot operate` command that changes it, run on the operator\'s\n// machine.\n\n/** The `didbot operate` line that admits or changes `record`\'s account. */\nfunction operateCommand(record) {\n  const operator = record.uri.replace(/^at:\\/\\//, \"\").split(\"/\")[0];\n  const line = document.createElement(\"code\");\n  line.textContent = `didbot operate ${hostOf(record.subject)} ${operator}`;\n  return line;\n}\n\n/**\n * The admission rows of a record this deployment hosts: what the account\n * is, what logs in as it, one line each, and the command that changes it.\n */\nfunction admission(record) {\n  const dl = definitionList([[\"kind\", record.kind]]);\n  const dt = document.createElement(\"dt\");\n  dt.textContent = \"logs in with\";\n  dl.append(dt);\n  const held = record.credentials || [];\n  for (const text of held.length > 0 ? held.map(credentialText) : [\"none\"]) {\n    const dd = document.createElement(\"dd\");\n    dd.textContent = text;\n    dl.append(dd);\n  }\n  const changeTitle = document.createElement(\"dt\");\n  changeTitle.textContent = \"change with\";\n  const change = document.createElement(\"dd\");\n  change.append(operateCommand(record));\n  dl.append(changeTitle, change);\n  return dl;\n}\n\n/** One row per record: where it is, who it names, when, and whether we host it. */\nfunction renderOperatorRecords(records) {\n  if (records.length === 0) {\n    const empty = document.createElement(\"p\");\n    empty.textContent = \"\\u27E6no-operator-records-tamarind\\u27E7\";\n    return empty;\n  }\n  const list = document.createElement(\"ul\");\n  list.className = \"operator-records\";\n  for (const record of records) {\n    const item = document.createElement(\"li\");\n    item.dataset.hosted = record.hostedHere ? \"here\" : \"elsewhere\";\n    item.append(\n      definitionList([\n        [\"record\", record.uri],\n        [\"subject\", record.subject],\n        [\"created\", record.createdAt || \"\\u27E6no-created-at-tamarind\\u27E7\"],\n        [\"hosted here\", record.hostedHere ? \"yes\" : \"no\"],\n      ])\n    );\n    if (record.hostedHere) item.append(admission(record));\n    list.append(item);\n  }\n  return list;\n}\n\n// --- The per-app panels ---------------------------------------------------\n//\n// One filter over three reads: live logins, refusals and writes, each from\n// its own `/dashboard/api/apps/*` route and each carrying its own\n// tri-state envelope. A log this deployment cannot list answers\n// `unreachable`, which is a different fact from an app that did nothing,\n// and the panel prints it as such.\n\n// What the filter is narrowing on, held here rather than read back out of\n// the form: the form is built by the signed-in render, the same as every\n// other control on this page, so a signed-out visitor is served a shell\n// with nothing on it to press.\nconst appFilter = { client: \"\", account: \"\", since: \"\" };\n\n/** The `?client=&account=` the panels are asking for. */\nfunction appQuery() {\n  const params = new URLSearchParams();\n  if (appFilter.client) params.set(\"client\", appFilter.client);\n  if (appFilter.account) params.set(\"account\", appFilter.account);\n  if (appFilter.since) params.set(\"since\", appFilter.since);\n  const query = params.toString();\n  return query ? `?${query}` : \"\";\n}\n\n/** One labelled text input, with the datalist it offers, if any. */\nfunction filterField(name, label, list) {\n  const wrap = document.createElement(\"p\");\n  wrap.className = \"filter-field\";\n  const input = document.createElement(\"input\");\n  input.type = \"text\";\n  input.id = `filter-${name}`;\n  input.name = name;\n  input.autocomplete = \"off\";\n  input.value = appFilter[name];\n  const element = document.createElement(\"label\");\n  element.htmlFor = input.id;\n  element.textContent = label;\n  wrap.append(element, input);\n  if (list) {\n    input.setAttribute(\"list\", list.id);\n    wrap.append(list);\n  }\n  return wrap;\n}\n\n/** Builds the filter form, once, into its own panel. */\nfunction renderAppFilter() {\n  const section = document.getElementById(\"panel-app-filter\");\n  const body = section.querySelector(\'[data-role=\"body\"]\');\n  if (section.dataset.state === \"live\") {\n    return;\n  }\n  const clients = document.createElement(\"datalist\");\n  clients.id = \"known-clients\";\n  const form = document.createElement(\"form\");\n  form.className = \"app-filter\";\n  form.append(\n    filterField(\"client\", \"client\", clients),\n    filterField(\"account\", \"account\", null),\n    filterField(\"since\", \"since\", null)\n  );\n  // The logs only: a live login has no time to narrow on.\n  form.elements.since.placeholder = \"7d\";\n  const controls = document.createElement(\"p\");\n  controls.className = \"filter-controls\";\n  const apply = document.createElement(\"button\");\n  apply.type = \"submit\";\n  apply.textContent = \"\\u27E6filter-apply-sedge\\u27E7\";\n  const clear = document.createElement(\"button\");\n  clear.type = \"button\";\n  clear.textContent = \"\\u27E6filter-clear-sedge\\u27E7\";\n  clear.addEventListener(\"click\", () => {\n    appFilter.client = \"\";\n    appFilter.account = \"\";\n    appFilter.since = \"\";\n    form.reset();\n    refreshApps();\n  });\n  controls.append(apply, clear);\n  form.append(controls);\n  form.addEventListener(\"submit\", (event) => {\n    event.preventDefault();\n    appFilter.client = form.elements.client.value.trim();\n    appFilter.account = form.elements.account.value.trim();\n    appFilter.since = form.elements.since.value.trim();\n    refreshApps();\n  });\n  body.replaceChildren(form);\n  section.dataset.state = \"live\";\n}\n\n// A `401` is not an error to report but the ordinary state of a page\n// nobody has signed in to, so it renders as the sign-in rather than as a\n// failure. `SIGNED_OUT` is what this hands back for that case, so a caller\n// can tell it from a panel that answered.\nconst SIGNED_OUT = Symbol(\"signed out\");\n\n/** Fetches one signed-in panel and renders it, or shows the sign-in. */\nasync function refreshSignedInPanel(id, path, renderLive) {\n  const section = document.getElementById(id);\n  let response;\n  try {\n    response = await operatorCall(path);\n  } catch (error) {\n    renderPanel(section, { status: \"unreachable\", reason: String(error) }, () => null);\n    return null;\n  }\n  if (response.status === 401) {\n    renderSignIn(section);\n    return SIGNED_OUT;\n  }\n  if (!response.ok) {\n    renderPanel(section, { status: \"unreachable\", reason: `${response.status}` }, () => null);\n    return null;\n  }\n  const panelState = await response.json();\n  renderPanel(section, panelState, renderLive);\n  return panelState;\n}\n\n/** Every client id the logins name, so the filter can offer them. */\nfunction offerClients(logins) {\n  const list = document.getElementById(\"known-clients\");\n  if (!list) {\n    return;\n  }\n  const seen = [...new Set(logins.map((login) => login.clientId))].sort();\n  list.replaceChildren(\n    ...seen.map((clientId) => {\n      const option = document.createElement(\"option\");\n      option.value = clientId;\n      return option;\n    })\n  );\n}\n\n// Ending an app\'s logins is not denying it: these end for good, and the\n// app may sign in again the moment it is able to. Denying it is a policy\n// edit, written from the policy dashboard, and it is reversible. Both\n// belong in an operator\'s hands and neither is the other, which is why\n// this button says what it does and nothing more.\nfunction endLoginsButton(clientId, account) {\n  const button = document.createElement(\"button\");\n  button.type = \"button\";\n  button.className = \"end-logins\";\n  button.textContent = \"\\u27E6end-logins-fernwhistle\\u27E7\";\n  button.addEventListener(\"click\", async () => {\n    if (!window.confirm(\"\\u27E6end-logins-confirm-fernwhistle\\u27E7\")) {\n      return;\n    }\n    await act(document.getElementById(\"panel-grants\"), \"/dashboard/api/apps/end-logins\", {\n      client: clientId,\n      account,\n    });\n    refreshApps();\n  });\n  return button;\n}\n\nfunction renderLogins(logins) {\n  return emptyOr(logins, (rows) => {\n    const element = table(\n      [\"client\", \"account\", \"approved\", \"in force\", \"policy\", \"login ends\", \"\"],\n      rows,\n      (row) => [\n        row.clientId,\n        row.account,\n        row.approvedScope,\n        row.scopeInForce,\n        row.admitted ? \"\\u27E6admits-marlinspike\\u27E7\" : \"\\u27E6refuses-marlinspike\\u27E7\",\n        row.refreshExpiresAt,\n        \"\",\n      ]\n    );\n    // The last cell of each row is left empty by `table` and filled here:\n    // a control is an element, not text, and the table helper only writes\n    // text.\n    const body = element.tBodies[0];\n    rows.forEach((row, index) => {\n      const cell = body.rows[index].cells[6];\n      cell.append(endLoginsButton(row.clientId, row.account));\n    });\n    return element;\n  });\n}\n\nfunction renderRefusals(refusals) {\n  return emptyOr(refusals, (rows) =>\n    table(\n      [\"closed\", \"client\", \"account\", \"verdict\", \"policies\", \"reason\", \"repeats\"],\n      rows,\n      (row) => [\n        row.closedAt,\n        row.clientId,\n        row.account,\n        row.verdict,\n        (row.policiesFired || []).join(\", \"),\n        row.reason,\n        row.repeats,\n      ]\n    )\n  );\n}\n\nfunction renderWrites(writes) {\n  return emptyOr(writes, (rows) =>\n    table(\n      [\"at\", \"client\", \"account\", \"action\", \"collection\", \"key\", \"rev\", \"policy\"],\n      rows,\n      (row) => [\n        row.at,\n        row.clientId,\n        row.account,\n        row.action,\n        row.collection,\n        row.rkey,\n        row.rev,\n        row.policy,\n      ]\n    )\n  );\n}\n\nasync function refreshApps() {\n  const query = appQuery();\n  const logins = await refreshSignedInPanel(\n    \"panel-grants\",\n    `/dashboard/api/apps/logins${query}`,\n    renderLogins\n  );\n  if (logins === SIGNED_OUT) {\n    renderSignIn(document.getElementById(\"panel-app-filter\"));\n  } else if (logins && logins.status === \"live\") {\n    renderAppFilter();\n    offerClients(logins.data);\n  }\n  await refreshSignedInPanel(\n    \"panel-refusal-log\",\n    `/dashboard/api/apps/refusals${query}`,\n    renderRefusals\n  );\n  await refreshSignedInPanel(\n    \"panel-writes\",\n    `/dashboard/api/apps/writes${query}`,\n    renderWrites\n  );\n}\n\n// --- The accounts panel ---------------------------------------------------\n//\n// Every account this deployment holds, from `/dashboard/api/accounts`, the\n// route `didbot account list` reads. It answers whatever `[disclosure]`\n// closes to the public. A row opens into the detail `didbot account show`\n// prints. With \"never wrote\" and an idle span narrowing it, the panel offers\n// the bulk delete `didbot account delete --never-wrote --confirm` runs: one\n// call to each account shown, each asking the server to check again that\n// the account never wrote and is still idle.\n\n// What the listing is narrowing on, held here for the reason `appFilter`\n// is: the form is rebuilt with every render.\nconst accountFilter = { kind: \"\", under: \"\", neverWrote: false, idleFor: \"\" };\n\n/** The `?kind=&under=&neverWrote=&idleFor=` the listing is asking for. */\nfunction accountQuery() {\n  const params = new URLSearchParams();\n  if (accountFilter.kind) params.set(\"kind\", accountFilter.kind);\n  if (accountFilter.under) params.set(\"under\", accountFilter.under);\n  if (accountFilter.neverWrote) params.set(\"neverWrote\", \"true\");\n  if (accountFilter.idleFor) params.set(\"idleFor\", accountFilter.idleFor);\n  const query = params.toString();\n  return query ? `?${query}` : \"\";\n}\n\nasync function refreshAccounts() {\n  await refreshSignedInPanel(\n    \"panel-accounts\",\n    `/dashboard/api/accounts${accountQuery()}`,\n    renderAccounts\n  );\n}\n\n/** A labelled control for one filter field. */\nfunction accountField(name, label, control) {\n  control.name = name;\n  return labelledField(`account-filter-${name}`, label, control);\n}\n\n/** `control` under its `<label>`. */\nfunction labelledField(id, label, control) {\n  const wrap = document.createElement(\"p\");\n  wrap.className = \"filter-field\";\n  control.id = id;\n  const element = document.createElement(\"label\");\n  element.htmlFor = control.id;\n  element.textContent = label;\n  wrap.append(element, control);\n  return wrap;\n}\n\n/** A `<select>` offering `values`, with \"\" meaning any. */\nfunction accountSelect(values, chosen) {\n  const select = document.createElement(\"select\");\n  for (const value of [\"\", ...values]) {\n    const option = document.createElement(\"option\");\n    option.value = value;\n    option.textContent = value || \"any\";\n    option.selected = value === chosen;\n    select.append(option);\n  }\n  return select;\n}\n\nfunction accountFilterForm() {\n  const form = document.createElement(\"form\");\n  form.className = \"account-filter\";\n  const under = document.createElement(\"input\");\n  under.type = \"text\";\n  under.autocomplete = \"off\";\n  under.value = accountFilter.under;\n  const neverWrote = document.createElement(\"input\");\n  neverWrote.type = \"checkbox\";\n  neverWrote.checked = accountFilter.neverWrote;\n  form.append(\n    accountField(\"kind\", \"kind\", accountSelect([\"agent\", \"host\", \"pipeline\", \"service\"], accountFilter.kind)),\n    accountField(\"under\", \"under\", under),\n    accountField(\"neverWrote\", \"never wrote\", neverWrote),\n    accountField(\"idleFor\", \"idle for\", accountSelect([\"1h\", \"1d\", \"7d\", \"30d\"], accountFilter.idleFor))\n  );\n  const controls = document.createElement(\"p\");\n  controls.className = \"filter-controls\";\n  const apply = document.createElement(\"button\");\n  apply.type = \"submit\";\n  apply.textContent = \"\\u27E6filter-apply-sedge\\u27E7\";\n  const clear = document.createElement(\"button\");\n  clear.type = \"button\";\n  clear.textContent = \"\\u27E6filter-clear-sedge\\u27E7\";\n  clear.addEventListener(\"click\", () => {\n    Object.assign(accountFilter, { kind: \"\", under: \"\", neverWrote: false, idleFor: \"\" });\n    refreshAccounts();\n  });\n  controls.append(apply, clear);\n  form.append(controls);\n  form.addEventListener(\"submit\", (event) => {\n    event.preventDefault();\n    accountFilter.kind = form.elements.kind.value;\n    accountFilter.under = form.elements.under.value.trim();\n    accountFilter.neverWrote = form.elements.neverWrote.checked;\n    accountFilter.idleFor = form.elements.idleFor.value;\n    refreshAccounts();\n  });\n  return form;\n}\n\n/** The hostname a `did:web` names, which is how a person reads it. */\nfunction hostOf(did) {\n  return did && did.startsWith(\"did:web:\") ? did.slice(8).replace(\"%3A\", \":\") : did || \"\";\n}\n\n/** An RFC 3339 time cut to the minute. */\nfunction minuteOf(at) {\n  return at ? at.slice(0, 16).replace(\"T\", \" \") : \"\";\n}\n\nfunction wroteOf(row) {\n  return row.wrote === true ? \"yes\" : row.wrote === false ? \"no\" : \"?\";\n}\n\nfunction standingOf(row) {\n  return row.edge && row.edge !== \"verified\" ? `${row.state}, ${row.edge}` : row.state;\n}\n\n/** `element` in a box that scrolls sideways when it is wider than the page. */\nfunction scrolling(element) {\n  const scroll = document.createElement(\"div\");\n  scroll.className = \"table-scroll\";\n  scroll.append(element);\n  return scroll;\n}\n\nfunction renderAccounts(rows) {\n  const wrap = document.createElement(\"div\");\n  wrap.append(accountFilterForm());\n  wrap.append(\n    definitionList([\n      [\"accounts\", String(rows.length)],\n      [\"never wrote\", String(rows.filter((row) => row.wrote === false).length)],\n    ])\n  );\n  const detail = document.createElement(\"div\");\n  detail.className = \"account-detail\";\n  detail.dataset.role = \"account-detail\";\n  wrap.append(\n    scrolling(emptyOr(rows, (shown) => {\n      const element = table(\n        [\"account\", \"kind\", \"records\", \"wrote\", \"last commit\", \"created\", \"state\", \"parent\", \"\"],\n        shown,\n        (row) => [\n          row.handle || hostOf(row.did),\n          row.kind,\n          row.records,\n          wroteOf(row),\n          minuteOf(row.lastCommit),\n          minuteOf(row.createdAt),\n          standingOf(row),\n          hostOf(row.parent),\n          \"\",\n        ]\n      );\n      const body = element.tBodies[0];\n      shown.forEach((row, index) => {\n        const button = document.createElement(\"button\");\n        button.type = \"button\";\n        button.className = \"show-account\";\n        button.textContent = \"\\u27E6show-account-sedge\\u27E7\";\n        button.addEventListener(\"click\", () => {\n          linkAccount(row.did);\n          showAccount(detail, row.did);\n        });\n        body.rows[index].cells[8].append(button);\n      });\n      return element;\n    }))\n  );\n  if (accountFilter.neverWrote && rows.length > 0) {\n    wrap.append(deleteNeverWroteButton(rows));\n  }\n  wrap.append(detail);\n  return wrap;\n}\n\n// `/dashboard#account=<did>` opens one account\'s detail, the same as its\n// row\'s button, and the button puts that address in the bar to copy.\n// Other sites link here, so the format is a contract: see\n// `docs/web-surfaces.md`. The DID is taken as written, or percent-encoded\n// once, and only ever as data.\nconst ACCOUNT_IN_HASH = /(?:^#|&)account=([^&]*)/;\nconst DID_SHAPE = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]*[A-Za-z0-9._%-]$/;\n\n/** The DID `#account=` names, or null when it names none or a malformed one. */\nfunction accountFromHash() {\n  const found = ACCOUNT_IN_HASH.exec(location.hash);\n  if (!found) return null;\n  let did = found[1];\n  if (!did.startsWith(\"did:\")) {\n    try {\n      did = decodeURIComponent(did);\n    } catch {\n      return null;\n    }\n  }\n  return did.length <= 2048 && DID_SHAPE.test(did) ? did : null;\n}\n\n/** Puts `#account=<did>` in the address bar, without a history entry. */\nfunction linkAccount(did) {\n  history.replaceState(null, \"\", `${location.pathname}${location.search}#account=${did}`);\n}\n\n/** Opens the account the hash names, when the accounts panel is drawn. */\nasync function openAccountFromHash() {\n  const did = accountFromHash();\n  const detail = document.querySelector(\'#panel-accounts [data-role=\"account-detail\"]\');\n  if (!did || !detail) return;\n  if (await showAccount(detail, did)) {\n    detail.scrollIntoView({ block: \"start\" });\n  }\n}\n\n/**\n * One account in full, read from the route `didbot account show` calls.\n * Answers whether it was shown.\n */\nasync function showAccount(detail, did) {\n  const section = document.getElementById(\"panel-accounts\");\n  let response;\n  try {\n    response = await operatorCall(`/dashboard/api/accounts/${encodeURIComponent(did)}`);\n  } catch (error) {\n    showRefusal(section, String(error));\n    return false;\n  }\n  if (!response.ok) {\n    showRefusal(section, `${response.status}`);\n    return false;\n  }\n  showRefusal(section, null);\n  const account = (await response.json()).data;\n  const heading = document.createElement(\"h3\");\n  heading.textContent = account.did;\n  const collections = Object.entries(account.collections || {});\n  detail.replaceChildren(\n    heading,\n    definitionList([\n      [\"handle\", account.handle || \"\"],\n      [\"kind\", account.kind],\n      [\"lineage\", (account.lineage || []).map(hostOf).join(\" > \")],\n      [\"created\", minuteOf(account.createdAt)],\n      [\"last commit\", minuteOf(account.lastCommit)],\n      [\"records\", String(account.records ?? \"\")],\n      [\"wrote\", wroteOf(account)],\n      [\"children\", String(account.children)],\n      [\"state\", standingOf(account)],\n      [\"locks\", account.locks ? JSON.stringify(account.locks) : \"none\"],\n    ]),\n    emptyOr(collections, (shown) =>\n      table([\"collection\", \"records\"], shown, ([collection, count]) => [collection, count])\n    ),\n    scrolling(\n      emptyOr(account.ledger.recent, (shown) =>\n        table([\"seq\", \"at\", \"event\", \"by\"], shown, (entry) => [\n          entry.seq,\n          minuteOf(entry.at),\n          entry.event,\n          entry.operator ? hostOf(entry.operator) : entry.party || \"\",\n        ])\n      )\n    ),\n    accountCredentials(detail, account)\n  );\n  return true;\n}\n\n// What logs in as the account, and the operator\'s change to it: the one\n// `didbot account credentials` makes through `bot.did.updateCredentials`,\n// at the path the session cookie reaches. Every record holds a public\n// half only. A change ends the account\'s sessions.\n\n/** A credential record as `didbot account credentials` prints it. */\nfunction credentialText(held) {\n  if (held.fingerprint) return `key ${held.fingerprint}`;\n  const claims = (held.credential.claims || []).map((claim) => `${claim.name}=${claim.value}`);\n  return `oidc ${held.credential.issuer} ${claims.join(\" \")}`;\n}\n\nfunction accountCredentials(detail, account) {\n  const wrap = document.createElement(\"div\");\n  wrap.className = \"account-credentials\";\n  wrap.append(\n    emptyOr(account.credentials || [], (shown) => {\n      const element = table([\"record\", \"credential\", \"\"], shown, (held) => [\n        held.rkey,\n        credentialText(held),\n        \"\",\n      ]);\n      shown.forEach((held, index) => {\n        const button = document.createElement(\"button\");\n        button.type = \"button\";\n        button.className = \"remove-credential\";\n        button.textContent = \"remove\";\n        // Removing takes a way to log in away, and ends every session the\n        // account holds, so it asks first and names the record.\n        button.addEventListener(\"click\", () => {\n          const asked = `remove ${held.rkey}: ${credentialText(held)}?\\n\\n\\u27E6credential-remove-confirm-fernwhistle\\u27E7`;\n          if (window.confirm(asked)) changeCredentials(detail, account.did, { remove: held.rkey });\n        });\n        element.tBodies[0].rows[index].cells[2].append(button);\n      });\n      return element;\n    })\n  );\n  wrap.append(addCredentialForm(detail, account.did));\n  return wrap;\n}\n\n// What each kind\'s one field holds, as `didbot account credentials` takes\n// it: `--key`, `--fingerprint` and `--oidc`.\nconst ADDABLE = {\n  key: \"z6Mk...\",\n  \"parked key\": \"SHA256:...\",\n  oidc: \"https://issuer.example claim=value\",\n};\n\n/** The `updateCredentials` body adding what the form names, or null. */\nfunction addBody(kind, value) {\n  if (kind === \"key\") {\n    return { add: { $type: \"bot.did.credential#publicKey\", publicKeyMultibase: value } };\n  }\n  if (kind === \"parked key\") return { parkedKey: value };\n  const [issuer, ...pairs] = value.split(/\\s+/);\n  const claims = pairs.map((pair) => {\n    const at = pair.indexOf(\"=\");\n    return at > 0 ? { name: pair.slice(0, at), value: pair.slice(at + 1) } : null;\n  });\n  if (claims.length === 0 || claims.includes(null)) return null;\n  return { add: { $type: \"bot.did.credential#oidcIdentity\", issuer, claims } };\n}\n\nfunction addCredentialForm(detail, did) {\n  const form = document.createElement(\"form\");\n  form.className = \"account-filter credential-add\";\n  const kind = document.createElement(\"select\");\n  for (const name of Object.keys(ADDABLE)) {\n    const option = document.createElement(\"option\");\n    option.value = name;\n    option.textContent = name;\n    kind.append(option);\n  }\n  const value = document.createElement(\"input\");\n  value.type = \"text\";\n  value.autocomplete = \"off\";\n  value.spellcheck = false;\n  value.required = true;\n  value.placeholder = ADDABLE[kind.value];\n  const valueField = labelledField(\"credential-add-value\", kind.value, value);\n  kind.addEventListener(\"change\", () => {\n    valueField.querySelector(\"label\").textContent = kind.value;\n    value.placeholder = ADDABLE[kind.value];\n  });\n  const controls = document.createElement(\"p\");\n  controls.className = \"filter-controls\";\n  const add = document.createElement(\"button\");\n  add.type = \"submit\";\n  add.textContent = \"add\";\n  controls.append(add);\n  form.append(labelledField(\"credential-add-kind\", \"add\", kind), valueField, controls);\n  form.addEventListener(\"submit\", (event) => {\n    event.preventDefault();\n    const body = addBody(kind.value, value.value.trim());\n    if (!body) {\n      showRefusal(document.getElementById(\"panel-accounts\"), `oidc: ${ADDABLE.oidc}`);\n      return;\n    }\n    changeCredentials(detail, did, body);\n  });\n  return form;\n}\n\n/** Sends one change, and redraws the account when the server took it. */\nasync function changeCredentials(detail, did, body) {\n  const section = document.getElementById(\"panel-accounts\");\n  const path = `/dashboard/api/accounts/${encodeURIComponent(did)}/credentials`;\n  if (await act(section, path, body)) await showAccount(detail, did);\n}\n\n/**\n * The accounts a bulk delete takes, and the ones it keeps: one with\n * accounts beneath it, and a host or service account unless the kind\n * filter names it. The same plan `didbot account delete --never-wrote`\n * makes.\n */\nfunction deletionPlan(rows) {\n  const plan = { delete: [], keep: [] };\n  for (const row of rows) {\n    const named = row.kind === accountFilter.kind;\n    const machine = row.kind === \"host\" || row.kind === \"service\";\n    if (row.children > 0 || (machine && !named)) {\n      plan.keep.push(row);\n    } else {\n      plan.delete.push(row);\n    }\n  }\n  return plan;\n}\n\n// Deleting is the operator hard delete, once per account: the row goes,\n// the ledger stays, and nothing brings it back. So it asks first, and says\n// how many.\nfunction deleteNeverWroteButton(rows) {\n  const plan = deletionPlan(rows);\n  const controls = document.createElement(\"p\");\n  controls.className = \"filter-controls\";\n  const button = document.createElement(\"button\");\n  button.type = \"button\";\n  button.className = \"delete-accounts\";\n  button.textContent = `\\u27E6delete-never-wrote-fernwhistle\\u27E7 (${plan.delete.length})`;\n  button.disabled = plan.delete.length === 0 || !accountFilter.idleFor;\n  button.addEventListener(\"click\", async () => {\n    if (!window.confirm(`\\u27E6delete-never-wrote-confirm-fernwhistle\\u27E7 (${plan.delete.length})`)) {\n      return;\n    }\n    const section = document.getElementById(\"panel-accounts\");\n    const kept = [];\n    for (const row of plan.delete) {\n      const response = await operatorCall(\n        `/dashboard/api/accounts/${encodeURIComponent(row.did)}/delete`,\n        {\n          neverWrote: true,\n          idleFor: accountFilter.idleFor,\n          kind: accountFilter.kind || undefined,\n        }\n      ).catch((error) => ({ ok: false, status: String(error) }));\n      if (!response.ok) kept.push(`${hostOf(row.did)} ${response.status}`);\n    }\n    await refreshAccounts();\n    showRefusal(section, kept.length > 0 ? kept.join(\", \") : null);\n  });\n  controls.append(button);\n  return controls;\n}\n\nasync function refreshAll() {\n  adoptCsrfFromUrl();\n  await refreshAbout();\n  await refreshEstop();\n  await refreshOperatorRecords();\n  await refreshAccounts();\n  await refreshApps();\n  for (const id of AWAITING_OAUTH) {\n    refreshStaticNotImplemented(id, [\"oauth\"]);\n  }\n  await openAccountFromHash();\n}\n\n// --- The background field -------------------------------------------------\n//\n// F\'s travelling-wave halftone field, over `/dashboard/wasm/site-anim.js`\n// (built by `scripts/build-dashboard-wasm.sh`, an opt-in step \u{2014} see\n// `crates/didbot-serve/src/dashboard.rs`\'s `wasm_asset` doc comment). Missing\n// wasm and `prefers-reduced-motion` both fall back to the same still,\n// undecorated background: the field is atmosphere, never a dependency of\n// the page working.\nasync function runField() {\n  const canvas = document.getElementById(\"field\");\n  const ctx = canvas.getContext(\"2d\");\n  let w, h;\n  function resize() {\n    w = canvas.width = innerWidth;\n    h = canvas.height = innerHeight;\n  }\n  resize();\n  addEventListener(\"resize\", resize);\n  const reduced = matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  // Dot pitch in CSS pixels. Halved from an earlier 12: at 12 the field\n  // read as individual blobs rather than a moire pattern -- the sampling\n  // lattice was too coarse, relative to the wave field\'s own spatial\n  // frequency, for beat interference to show. A further halving to 4 (a\n  // third of the original 12) was tried and measured, not just eyeballed:\n  // at 1440x1000 it costs ~330ms/frame (~3fps, most of it inside the wasm\n  // `field_ptr()` call below, which resamples every point/plane source at\n  // every one of cols*rows cells -- not the canvas drawing, which this\n  // file already batches into one `fill()`) against ~61ms/frame (~16fps)\n  // at 6. A synchronous 330ms `requestAnimationFrame` callback blocks the\n  // main thread long enough to feel like the whole page hung, not just the\n  // decoration -- unacceptable for a background field. 6 is the finer\n  // pitch that stays a page decoration rather than becoming the page\'s\n  // performance problem. Making this cheaper further needs either fewer\n  // point sources (the `7` a few lines down) or changes inside\n  // `crates/didbot-site-anim/src/wave.rs` itself, which this file does not\n  // own.\n  const CELL = 6;\n\n  let mod, wasm;\n  try {\n    const dynamicImport = new Function(\"url\", \"return import(url)\");\n    mod = await dynamicImport(\"/dashboard/wasm/site-anim.js\");\n    wasm = await mod.default();\n  } catch {\n    // No wasm built \u{2014} leave the still paper background alone. This is not\n    // an error state for the page: the field is decoration, and its own\n    // absence is not something an operator needs told about the way a\n    // missing panel is.\n    return;\n  }\n\n  const cols = Math.ceil(w / CELL);\n  const rows = Math.ceil(h / CELL);\n  const field = new mod.WaveFieldAnim(cols, rows, 7);\n\n  function draw() {\n    const ptr = field.field_ptr();\n    const bytes = new Uint8Array(wasm.memory.buffer, ptr, cols * rows);\n    ctx.fillStyle = \"#ececec\";\n    ctx.fillRect(0, 0, w, h);\n    // Full opacity, matching proto-f\'s own halftone (`site/src/pages/proto-f/`):\n    // a near-transparent fill (an earlier build used \"#0a0a0a22\", ~13% alpha)\n    // crushes the field\'s [-1,1] amplitude range into a barely-visible band of\n    // grey and reads as a flat, static grid rather than a travelling-wave\n    // moire. Solid dots are safe here because every panel and readout box\n    // sits on its own opaque `--panel-bg` above the field (z-index 1 over 0),\n    // so full contrast underneath never touches a number or a label.\n    ctx.fillStyle = \"#0a0a0a\";\n    // One `beginPath`/`fill` pair for the whole field, not one per dot:\n    // `moveTo` opens a new subpath without a connecting line, so batching\n    // every dot into a single path costs one `fill()` call instead of\n    // cols*rows of them. Measured at CELL=6, 1440x1000: this cuts the\n    // canvas-side cost from ~20ms (separate fill() per dot) to under 6ms.\n    // It is still not most of the frame, though -- see the CELL comment\n    // above `runField` for where the rest of the time actually goes.\n    ctx.beginPath();\n    for (let r = 0; r < rows; r++) {\n      for (let c = 0; c < cols; c++) {\n        const v = bytes[r * cols + c] / 255;\n        const radius = (CELL / 2) * v;\n        if (radius < 0.4) continue;\n        const cx = c * CELL + CELL / 2;\n        const cy = r * CELL + CELL / 2;\n        ctx.moveTo(cx + radius, cy);\n        ctx.arc(cx, cy, radius, 0, Math.PI * 2);\n      }\n    }\n    ctx.fill();\n  }\n\n  if (reduced) {\n    field.step(3);\n    draw();\n    return;\n  }\n\n  let last = performance.now();\n  function frame(now) {\n    const dt = (now - last) / 1000;\n    last = now;\n    field.step(dt * 0.6);\n    draw();\n    requestAnimationFrame(frame);\n  }\n  draw();\n  requestAnimationFrame(frame);\n}\n\naddEventListener(\"hashchange\", openAccountFromHash);\nrefreshAll();\nrunField().catch(() => {});\n";