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";